From 11c39b3c533fc4a094664603a5bf2834d24f2422 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Fri, 9 Oct 2026 17:38:41 +0800 Subject: [PATCH 001/106] feat: integrate standalone web app into shared workspace --- .gitignore | 3 + .prettierignore | 5 + README.md | 4 + apps/web-ui/LICENSE | 21 + apps/web-ui/README.md | 11 + apps/web-ui/THIRD_PARTY_NOTICES.md | 297 + apps/web-ui/apps/web/.npmignore | 1 + apps/web-ui/apps/web/index.html | 34 + apps/web-ui/apps/web/package.json | 18 + apps/web-ui/apps/web/product-isolation.ts | 121 + .../apps/web/public/apple-touch-icon.png | Bin 0 -> 1732 bytes apps/web-ui/apps/web/public/favicon-dark.svg | 1 + apps/web-ui/apps/web/public/favicon.svg | 1 + apps/web-ui/apps/web/public/icon-192.png | Bin 0 -> 1872 bytes apps/web-ui/apps/web/public/icon-512.png | Bin 0 -> 5202 bytes .../apps/web/public/manifest.webmanifest | 15 + apps/web-ui/apps/web/public/sw.js | 58 + apps/web-ui/apps/web/src/main.ts | 61 + apps/web-ui/apps/web/src/node-module-stub.ts | 12 + apps/web-ui/apps/web/src/preview.ts | 16 + apps/web-ui/apps/web/src/vite-env.d.ts | 1 + .../stress-tests/reasoning-chunks.stress.ts | 269 + apps/web-ui/apps/web/tsconfig.json | 4 + apps/web-ui/apps/web/vite.config.ts | 274 + apps/web-ui/import-manifest.json | 150 + apps/web-ui/package.json | 19 + .../account-controller/generated/remote.d.ts | 43 + .../account-controller/generated/remote.js | 456 + .../api/account-controller/package.json | 30 + .../api/account-controller/src/index.ts | 156 + .../api/account-controller/src/types.ts | 12 + apps/web-ui/packages/api/gateway/package.json | 43 + .../packages/api/gateway/src/client/index.ts | 969 ++ .../api/gateway/src/client/journal-stream.ts | 604 + .../api/gateway/src/client/remote-events.ts | 354 + .../api/gateway/src/client/remote-stream.ts | 230 + .../api/gateway/src/client/snapshot-stream.ts | 96 + .../api/gateway/src/client/stream-client.ts | 515 + apps/web-ui/packages/api/gateway/src/index.ts | 1685 +++ .../api/gateway/src/remote-error-codes.ts | 37 + .../api/gateway/src/stream-protocol.ts | 399 + .../packages/api/gateway/src/stream-server.ts | 480 + apps/web-ui/packages/api/gateway/src/types.ts | 179 + .../packages/api/gateway/tsdown.config.ts | 3 + .../api/job-controller/generated/remote.d.ts | 27 + .../api/job-controller/generated/remote.js | 181 + .../packages/api/job-controller/package.json | 46 + .../api/job-controller/src/client/index.ts | 47 + .../api/job-controller/src/client/model.ts | 223 + .../api/job-controller/src/client/service.ts | 298 + .../packages/api/job-controller/src/index.ts | 142 + .../api/job-controller/src/observe.ts | 134 + .../packages/api/job-controller/src/rows.ts | 53 + .../packages/api/job-controller/src/types.ts | 90 + .../packages/api/job-controller/src/wake.ts | 60 + .../api/job-controller/tsdown.config.ts | 5 + apps/web-ui/packages/api/remotes/package.json | 59 + .../packages/api/remotes/src/client/index.ts | 251 + apps/web-ui/packages/api/remotes/src/index.ts | 182 + .../packages/api/remotes/src/remote-events.ts | 48 + apps/web-ui/packages/api/remotes/src/types.ts | 19 + .../packages/api/remotes/tsdown.config.ts | 5 + .../session-controller/generated/remote.d.ts | 74 + .../session-controller/generated/remote.js | 1472 ++ .../api/session-controller/package.json | 59 + .../api/session-controller/src/agent.ts | 571 + .../src/archived-session-gate.ts | 57 + .../src/assistant-stream.ts | 103 + .../api/session-controller/src/catalog.ts | 132 + .../src/client/contract/events.ts | 207 + .../src/client/contract/session.ts | 159 + .../src/client/contract/sessions.ts | 167 + .../src/client/contract/snapshot.ts | 90 + .../session-controller/src/client/index.ts | 171 + .../src/client/ordered-baseline.ts | 43 + .../session-controller/src/client/scope.ts | 76 + .../src/client/session-wire-event.ts | 56 + .../src/client/sessions/assistant-stream.ts | 240 + .../src/client/sessions/history-records.ts | 33 + .../src/client/sessions/lineage.ts | 83 + .../src/client/sessions/manager.ts | 944 ++ .../src/client/sessions/notifier.ts | 97 + .../src/client/sessions/projection-store.ts | 260 + .../src/client/sessions/remotes.ts | 48 + .../src/client/sessions/service.ts | 779 + .../src/client/sessions/session.ts | 1038 ++ .../src/client/time-zone.ts | 14 + .../src/client/transport.ts | 252 + .../api/session-controller/src/commands.ts | 751 + .../api/session-controller/src/control.ts | 127 + .../session-controller/src/file-references.ts | 38 + .../api/session-controller/src/history.ts | 528 + .../api/session-controller/src/index.ts | 591 + .../api/session-controller/src/list.ts | 388 + .../src/media-references.ts | 81 + .../src/model-selection-projection.ts | 87 + .../session-controller/src/remote-events.ts | 13 + .../session-controller/src/skill-catalog.ts | 108 + .../api/session-controller/src/types.ts | 634 + .../api/session-controller/tsdown.config.ts | 5 + .../settings-controller/generated/remote.d.ts | 42 + .../settings-controller/generated/remote.js | 390 + .../api/settings-controller/package.json | 30 + .../settings-controller/src/credentials.ts | 161 + .../api/settings-controller/src/index.ts | 298 + .../api/settings-controller/src/types.ts | 38 + .../terminal-controller/generated/remote.d.ts | 52 + .../terminal-controller/generated/remote.js | 605 + .../api/terminal-controller/package.json | 45 + .../src/client/bindings.ts | 75 + .../src/client/close-requests.ts | 89 + .../terminal-controller/src/client/index.ts | 313 + .../terminal-controller/src/client/model.ts | 459 + .../src/client/retention.ts | 92 + .../src/client/shell-preference.ts | 26 + .../api/terminal-controller/src/index.ts | 548 + .../api/terminal-controller/src/retention.ts | 168 + .../api/terminal-controller/src/shells.ts | 86 + .../api/terminal-controller/src/stream.ts | 81 + .../api/terminal-controller/src/terminal.ts | 266 + .../api/terminal-controller/src/types.ts | 75 + .../api/terminal-controller/tsdown.config.ts | 5 + .../generated/remote.d.ts | 53 + .../workspace-controller/generated/remote.js | 521 + .../api/workspace-controller/package.json | 53 + .../workspace-controller/src/client/index.ts | 143 + .../workspace-controller/src/client/model.ts | 485 + .../src/client/service.ts | 200 + .../api/workspace-controller/src/commands.ts | 270 + .../src/default-directory.ts | 98 + .../src/default-workspace.ts | 30 + .../src/directory-picker.ts | 188 + .../api/workspace-controller/src/feed.ts | 190 + .../api/workspace-controller/src/index.ts | 202 + .../api/workspace-controller/src/types.ts | 176 + .../api/workspace-controller/tsdown.config.ts | 5 + .../api/workspace-files/generated/remote.d.ts | 36 + .../api/workspace-files/generated/remote.js | 336 + .../packages/api/workspace-files/package.json | 43 + .../api/workspace-files/src/changes.ts | 187 + .../workspace-files/src/client/change-feed.ts | 309 + .../api/workspace-files/src/client/index.ts | 35 + .../workspace-files/src/client/provider.ts | 155 + .../api/workspace-files/src/client/remote.ts | 50 + .../api/workspace-files/src/client/types.ts | 49 + .../packages/api/workspace-files/src/index.ts | 586 + .../packages/api/workspace-files/src/types.ts | 177 + .../api/workspace-files/tsdown.config.ts | 5 + .../boot/plugin-manager/generated/remote.d.ts | 45 + .../boot/plugin-manager/generated/remote.js | 733 + .../packages/boot/plugin-manager/package.json | 42 + .../boot/plugin-manager/src/build-approval.ts | 59 + .../boot/plugin-manager/src/failure.ts | 34 + .../plugin-manager/src/github-connection.ts | 94 + .../packages/boot/plugin-manager/src/index.ts | 1191 ++ .../plugin-manager/src/install-failure.ts | 47 + .../boot/plugin-manager/src/install-spec.ts | 148 + .../boot/plugin-manager/src/operations.ts | 832 ++ .../packages/boot/plugin-manager/src/patch.ts | 48 + .../boot/plugin-manager/src/registry.ts | 118 + .../boot/plugin-manager/src/run-tree.ts | 88 + .../packages/boot/plugin-manager/src/tools.ts | 192 + .../packages/boot/plugin-manager/src/types.ts | 281 + .../packages/client/connection/package.json | 29 + .../client/connection/src/api-path.ts | 7 + .../connection/src/api-request-trust.ts | 124 + .../client/connection/src/browser-auth.ts | 350 + .../client/connection/src/client/api.ts | 25 + .../connection/src/client/connection.ts | 334 + .../client/connection/src/client/index.ts | 351 + .../connection/src/client/random-uuid.ts | 14 + .../client/connection/src/client/rpc.ts | 220 + .../client/connection/src/http-bridge.ts | 130 + .../packages/client/connection/src/index.ts | 173 + .../connection/src/loopback-hostname.ts | 20 + .../client/connection/src/operator-peer.ts | 33 + .../client/connection/src/recovery-config.ts | 46 + .../client/connection/src/rpc-host.ts | 340 + .../client/connection/src/rpc-schema.ts | 56 + .../packages/client/connection/src/rpc.ts | 289 + .../client/connection/tsdown.config.ts | 3 + .../client/file-upload/generated/remote.d.ts | 26 + .../client/file-upload/generated/remote.js | 68 + .../packages/client/file-upload/package.json | 46 + .../client/file-upload/src/client/contract.ts | 33 + .../client/file-upload/src/client/index.ts | 26 + .../client/file-upload/src/client/runtime.ts | 369 + .../client/file-upload/src/http-route.ts | 87 + .../packages/client/file-upload/src/index.ts | 281 + .../client/file-upload/src/protocol.ts | 8 + .../packages/client/file-upload/src/types.ts | 36 + .../client/file-upload/tsdown.config.ts | 3 + .../packages/client/locale/package.json | 37 + .../locale/src/client/LanguageRow.module.css | 51 + .../client/locale/src/client/LanguageRow.tsx | 72 + .../client/locale/src/client/bootstrap.ts | 37 + .../client/locale/src/client/index.ts | 668 + .../locale/src/client/settings-store.ts | 52 + .../client/locale/src/css-modules.d.ts | 6 + .../packages/client/locale/src/index.ts | 38 + .../client/locale/src/locale-settings.ts | 35 + .../packages/client/locale/src/locales/en.ts | 46 + .../client/locale/src/locales/index.ts | 8 + .../client/locale/src/locales/settings.ts | 14 + .../packages/client/locale/src/locales/zh.ts | 47 + .../packages/client/locale/tsdown.config.ts | 3 + .../packages/client/modules/package.json | 27 + .../client/modules/src/client/entries.ts | 268 + .../modules/src/client/entry-lifecycle.ts | 28 + .../client/modules/src/client/index.ts | 84 + .../client/modules/src/client/manifest.ts | 465 + .../client/modules/src/client/system.ts | 550 + .../packages/client/modules/src/index.ts | 1244 ++ .../packages/client/modules/tsdown.config.ts | 3 + .../product-analytics/generated/remote.d.ts | 27 + .../product-analytics/generated/remote.js | 220 + .../client/product-analytics/package.json | 41 + .../product-analytics/src/client/index.ts | 79 + .../client/product-analytics/src/events.ts | 79 + .../client/product-analytics/src/index.ts | 125 + .../client/product-analytics/tsdown.config.ts | 5 + .../packages/client/resources/package.json | 30 + .../client/resources/src/client/contract.ts | 118 + .../client/resources/src/client/index.ts | 46 + .../client/resources/src/client/resources.ts | 232 + .../packages/client/resources/src/index.ts | 4 + .../client/resources/tsdown.config.ts | 3 + .../packages/client/shortcuts/package.json | 39 + .../packages/client/shortcuts/src/binding.ts | 178 + .../client/shortcuts/src/client/dom.ts | 175 + .../client/shortcuts/src/client/index.ts | 272 + .../client/shortcuts/src/client/native.ts | 81 + .../client/shortcuts/src/client/registry.ts | 278 + .../client/shortcuts/src/client/storage.ts | 64 + .../client/shortcuts/src/client/types.ts | 151 + .../packages/client/shortcuts/src/config.ts | 13 + .../client/shortcuts/src/configuration.ts | 371 + .../packages/client/shortcuts/src/index.ts | 20 + .../packages/client/shortcuts/src/native.ts | 33 + .../client/shortcuts/src/persistence.ts | 212 + .../packages/client/shortcuts/src/protocol.ts | 5 + .../client/shortcuts/tsdown.config.ts | 7 + .../web-ui/packages/client/store/package.json | 19 + .../packages/client/store/src/contract.ts | 148 + .../web-ui/packages/client/store/src/index.ts | 293 + .../packages/client/store/tsdown.config.ts | 3 + apps/web-ui/packages/client/tsdown.client.ts | 807 + .../packages/client/ui-approval/package.json | 36 + .../src/client/ApprovalPanel.module.css | 67 + .../ui-approval/src/client/ApprovalPanel.tsx | 149 + .../ui-approval/src/client/contract/slots.ts | 200 + .../client/ui-approval/src/client/index.ts | 127 + .../client/ui-approval/src/client/locales.ts | 22 + .../client/ui-approval/src/css-modules.d.ts | 4 + .../packages/client/ui-approval/src/index.ts | 4 + .../client/ui-approval/tsdown.config.ts | 3 + .../client/ui-attachment/package.json | 36 + .../src/AttachmentRail.module.css | 115 + .../ui-attachment/src/AttachmentRail.tsx | 188 + .../ui-attachment/src/DropOverlay.module.css | 56 + .../client/ui-attachment/src/DropOverlay.tsx | 132 + .../ui-attachment/src/FileCard.module.css | 171 + .../client/ui-attachment/src/FileCard.tsx | 95 + .../ui-attachment/src/MessageImage.module.css | 110 + .../client/ui-attachment/src/MessageImage.tsx | 242 + .../src/client/ComposerAttachments.module.css | 68 + .../src/client/ComposerAttachments.tsx | 146 + .../src/client/MessageImages.tsx | 24 + .../ui-attachment/src/client/drop-events.ts | 94 + .../client/ui-attachment/src/client/index.ts | 54 + .../client/ui-attachment/src/client/labels.ts | 79 + .../client/ui-attachment/src/css-modules.d.ts | 6 + .../client/ui-attachment/src/index.ts | 4 + .../client/ui-attachment/tsdown.config.ts | 3 + .../client/ui-brand-official/package.json | 31 + .../ui-brand-official/src/client/Brand.tsx | 50 + .../ui-brand-official/src/client/index.ts | 27 + .../client/ui-brand-official/src/index.ts | 7 + .../client/ui-brand-official/tsdown.config.ts | 3 + .../packages/client/ui-chat/package.json | 50 + .../client/ui-chat/src/chat-settings.ts | 74 + .../client/ui-chat/src/client/apply.ts | 417 + .../src/client/chat/ApprovalCommand.tsx | 47 + .../client/chat/AssistantMarkdown.module.css | 80 + .../src/client/chat/AssistantMarkdown.tsx | 194 + .../src/client/chat/AssistantNodeView.tsx | 63 + .../src/client/chat/ChatGroupSeat.module.css | 141 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 292 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 234 + .../src/client/chat/ChatView.module.css | 295 + .../ui-chat/src/client/chat/ChatView.tsx | 447 + .../src/client/chat/CommandNodeView.tsx | 44 + .../src/client/chat/CompactionCommandCard.tsx | 26 + .../src/client/chat/CompactionItem.tsx | 82 + .../src/client/chat/ContextBody.module.css | 160 + .../ui-chat/src/client/chat/ContextBody.tsx | 645 + .../chat/ContextInjectionRow.module.css | 68 + .../src/client/chat/ContextInjectionRow.tsx | 147 + .../client/chat/GenericCommandCard.module.css | 49 + .../src/client/chat/GenericCommandCard.tsx | 95 + .../client/chat/MessageIconActions.module.css | 115 + .../src/client/chat/MessageIconActions.tsx | 148 + .../src/client/chat/MessageItem.module.css | 364 + .../ui-chat/src/client/chat/MessageItem.tsx | 458 + .../src/client/chat/QuotaNoticeHost.tsx | 42 + .../src/client/chat/ReasoningRow.module.css | 77 + .../ui-chat/src/client/chat/ReasoningRow.tsx | 121 + .../ui-chat/src/client/chat/RunningStatus.tsx | 54 + .../src/client/chat/RunningWhaleTail.tsx | 26 + .../src/client/chat/StatsPills.module.css | 59 + .../ui-chat/src/client/chat/StatsPills.tsx | 373 + .../src/client/chat/SystemPromptRow.tsx | 53 + .../src/client/chat/TurnNavigator.module.css | 232 + .../ui-chat/src/client/chat/TurnNavigator.tsx | 363 + .../chat/TurnProcessNodeView.module.css | 60 + .../src/client/chat/TurnProcessNodeView.tsx | 79 + .../client/chat/TurnTailNodeView.module.css | 12 + .../src/client/chat/TurnTailNodeView.tsx | 117 + .../chat/TurnTriggerNodeView.module.css | 72 + .../src/client/chat/TurnTriggerNodeView.tsx | 76 + .../src/client/chat/TurnUsagePanel.module.css | 73 + .../src/client/chat/TurnUsagePanel.tsx | 116 + .../src/client/chat/accessibility.module.css | 8 + .../ui-chat/src/client/chat/message-chrome.ts | 116 + .../client/chat/register-node-renderers.ts | 144 + .../ui-chat/src/client/chat/render-entry.ts | 19 + .../src/client/chat/running-whale@2x.png | Bin 0 -> 21631 bytes .../src/client/chat/searchable-hidden.ts | 33 + .../src/client/chat/stat-dialog.module.css | 95 + .../ui-chat/src/client/chat/stat-dialog.ts | 81 + .../ui-chat/src/client/chat/step-process.ts | 30 + .../ui-chat/src/client/chat/token-format.ts | 102 + .../ui-chat/src/client/chat/turn-assistant.ts | 10 + .../src/client/chat/turn-rail-items.ts | 98 + .../ui-chat/src/client/chat/turn-trigger.ts | 83 + .../src/client/chat/use-calendar-day.ts | 27 + .../src/client/chat/use-chat-navigation.ts | 227 + .../src/client/chat/use-chat-reading.ts | 274 + .../src/client/chat/use-chat-scroll.ts | 200 + .../src/client/chat/use-chat-viewport.ts | 535 + .../ui-chat/src/client/chat/use-disclosure.ts | 38 + .../src/client/chat/use-process-scroll.ts | 123 + .../src/client/chat/use-scroll-follow.ts | 203 + .../ui-chat/src/client/chat/use-turn-data.ts | 25 + .../src/client/contract/assistant-content.ts | 15 + .../ui-chat/src/client/contract/chat-nodes.ts | 135 + .../src/client/contract/chat-visibility.ts | 20 + .../src/client/contract/process-groups.ts | 37 + .../ui-chat/src/client/contract/slots.ts | 364 + .../ui-chat/src/client/contract/snapshot.ts | 187 + .../ui-chat/src/client/contract/store.ts | 15 + .../src/client/contract/turn-metrics.ts | 43 + .../src/client/contract/turn-process.ts | 76 + .../conversation-nodes/README.i18n.yaml | 58 + .../src/client/conversation-nodes/README.md | 318 + .../client/conversation-nodes/README.zh.md | 318 + .../client/conversation-nodes/assistant.ts | 407 + .../chat-snapshot-builder.ts | 1336 ++ .../src/client/conversation-nodes/command.ts | 241 + .../src/client/conversation-nodes/common.ts | 68 + .../client/conversation-nodes/compaction.ts | 70 + .../conversation-nodes/event-projection.ts | 200 + .../src/client/conversation-nodes/fallback.ts | 44 + .../src/client/conversation-nodes/inbox.ts | 149 + .../src/client/conversation-nodes/message.ts | 165 + .../conversation-nodes/process-activity.ts | 197 + .../conversation-nodes/process-groups.ts | 397 + .../src/client/conversation-nodes/register.ts | 38 + .../conversation-nodes/request-prompt.ts | 188 + .../src/client/conversation-nodes/retry.ts | 105 + .../src/client/conversation-nodes/tool.ts | 436 + .../client/conversation-nodes/turn-error.ts | 117 + .../conversation-nodes/turn-max-tokens.ts | 87 + .../conversation-nodes/turn-navigation.ts | 109 + .../turn-process-presentation.ts | 165 + .../client/conversation-nodes/turn-process.ts | 369 + .../client/conversation-nodes/turn-tail.ts | 192 + .../client/ui-chat/src/client/index.ts | 129 + .../client/ui-chat/src/client/locale.ts | 389 + .../ui-chat/src/client/markdown-labels.ts | 24 + .../src/client/model/conversation-context.ts | 25 + .../src/client/model/steering-history.ts | 64 + .../src/client/model/tool-call-tree.ts | 204 + .../ui-chat/src/client/performance-usage.ts | 40 + .../ui-chat/src/client/presentation-policy.ts | 78 + .../src/client/settings/LinkOpeningRow.tsx | 55 + .../client/settings/PerformanceUsageRow.tsx | 50 + .../client/settings/PreferenceRow.module.css | 56 + .../src/client/settings/PreferenceRow.tsx | 70 + .../src/client/settings/TranscriptViewRow.tsx | 54 + .../client/ui-chat/src/client/stores.ts | 47 + .../ui-chat/src/client/transcript-view.ts | 61 + .../client/ui-chat/src/css-modules.d.ts | 6 + .../packages/client/ui-chat/src/index.ts | 46 + .../packages/client/ui-chat/tsdown.config.ts | 33 + .../packages/client/ui-codexhost/package.json | 35 + .../ui-codexhost/src/client/ImportIcon.tsx | 14 + .../src/client/ImportPage.module.css | 152 + .../ui-codexhost/src/client/ImportPage.tsx | 206 + .../src/client/NotificationsRow.module.css | 50 + .../src/client/NotificationsRow.tsx | 83 + .../client/ui-codexhost/src/client/api.ts | 59 + .../client/ui-codexhost/src/client/index.ts | 111 + .../client/ui-codexhost/src/client/locales.ts | 56 + .../ui-codexhost/src/client/notifications.ts | 73 + .../client/ui-codexhost/src/css-modules.d.ts | 4 + .../packages/client/ui-codexhost/src/index.ts | 7 + .../client/ui-codexhost/tsdown.config.ts | 3 + .../packages/client/ui-commands/package.json | 38 + .../src/client/PopupSelectView.module.css | 143 + .../src/client/PopupSelectView.tsx | 269 + .../client/ui-commands/src/client/contract.ts | 150 + .../ui-commands/src/client/directory.ts | 192 + .../client/ui-commands/src/client/index.ts | 91 + .../client/ui-commands/src/client/locales.ts | 73 + .../ui-commands/src/client/option-groups.ts | 27 + .../client/ui-commands/src/client/popup.ts | 397 + .../ui-commands/src/client/presentation.ts | 103 + .../ui-commands/src/client/resolution.ts | 61 + .../client/ui-commands/src/client/service.ts | 552 + .../client/ui-commands/src/css-modules.d.ts | 6 + .../packages/client/ui-commands/src/index.ts | 10 + .../client/ui-commands/tsdown.config.ts | 3 + .../client/ui-conversation/package.json | 56 + .../ui-conversation/src/client/apply.ts | 711 + .../src/client/context-occupancy.ts | 25 + .../src/client/contract/composer-blocks.ts | 29 + .../client/contract/composer-submission.ts | 27 + .../src/client/contract/context-producer.ts | 32 + .../src/client/contract/conversation.ts | 343 + .../src/client/contract/draft-editor.ts | 120 + .../src/client/contract/groups.ts | 135 + .../src/client/contract/input.ts | 402 + .../src/client/contract/records.ts | 315 + .../src/client/contract/request-inspection.ts | 181 + .../src/client/contract/slots.ts | 562 + .../src/client/contract/snapshot.ts | 35 + .../src/client/contract/system-prompt.ts | 107 + .../src/client/contract/views.ts | 29 + .../src/client/conversation/assembler.ts | 1172 ++ .../src/client/conversation/assembly.ts | 348 + .../conversation/definition-registry.ts | 72 + .../src/client/conversation/event-registry.ts | 129 + .../src/client/conversation/group-registry.ts | 50 + .../src/client/conversation/group-store.ts | 260 + .../client/conversation/historical-images.ts | 200 + .../src/client/conversation/location-index.ts | 723 + .../src/client/conversation/view-registry.ts | 19 + .../ui-conversation/src/client/draft.ts | 94 + .../src/client/image-labels.ts | 66 + .../ui-conversation/src/client/index.ts | 187 + .../src/client/input/blocks.ts | 44 + .../src/client/input/decorations.ts | 71 + .../input/editor/ComposerContentEditable.tsx | 56 + .../client/input/editor/DecoratorPortals.tsx | 43 + .../src/client/input/editor/DraftEditor.tsx | 78 + .../input/editor/ReferenceChip.module.css | 32 + .../src/client/input/editor/ReferenceChip.tsx | 48 + .../src/client/input/editor/chip-node.tsx | 217 + .../src/client/input/editor/claim-decor.ts | 72 + .../input/editor/composer-editor.module.css | 24 + .../src/client/input/editor/keymap.ts | 226 + .../src/client/input/editor/projection.ts | 251 + .../input/editor/reference-activation.ts | 46 + .../src/client/input/editor/runtime.ts | 415 + .../src/client/input/editor/span-map.ts | 126 + .../src/client/input/editor/text-ref.ts | 129 + .../src/client/input/editor/view-binding.ts | 171 + .../src/client/input/facade.ts | 1020 ++ .../ui-conversation/src/client/input/hub.ts | 336 + .../src/client/input/machine.ts | 315 + .../src/client/input/submission-analytics.ts | 30 + .../src/client/input/submission-policy.ts | 92 + .../ui-conversation/src/client/locales.ts | 767 + .../src/client/queue/QueueDock.module.css | 251 + .../src/client/queue/QueueDock.tsx | 516 + .../ui-conversation/src/client/service.ts | 677 + .../settings/EnterBehaviorRow.module.css | 56 + .../src/client/settings/EnterBehaviorRow.tsx | 79 + .../client/skeleton/ContextMeter.module.css | 154 + .../src/client/skeleton/ContextMeter.tsx | 201 + .../client/skeleton/ConversationContent.tsx | 238 + .../client/skeleton/ConversationHeader.tsx | 43 + .../client/skeleton/ConversationMainPanel.tsx | 67 + .../src/client/skeleton/ConversationPanel.tsx | 14 + .../skeleton/ConversationRoot.module.css | 547 + .../src/client/skeleton/ConversationRoot.tsx | 13 + .../client/skeleton/ConversationSession.tsx | 197 + .../skeleton/ConversationWidthControls.tsx | 205 + .../skeleton/DefaultConversationViews.tsx | 60 + .../src/client/skeleton/EmptyHero.tsx | 184 + .../src/client/skeleton/HeroShell.module.css | 232 + .../src/client/skeleton/InputBar.module.css | 441 + .../src/client/skeleton/InputBar.tsx | 622 + .../src/client/skeleton/TodoPanel.module.css | 117 + .../src/client/skeleton/TodoPanel.tsx | 138 + .../src/client/skeleton/control-row-layout.ts | 46 + .../src/client/skeleton/safari.ts | 44 + .../src/client/stop-sequence.ts | 61 + .../src/client/stop-shortcut.ts | 117 + .../ui-conversation/src/client/stores.ts | 83 + .../src/client/view-selection.ts | 21 + .../ui-conversation/src/css-modules.d.ts | 6 + .../client/ui-conversation/src/index.ts | 38 + .../src/submission-settings.ts | 32 + .../client/ui-conversation/tsdown.config.ts | 3 + .../ui-directory-picker-browse/package.json | 35 + .../src/client/DirectoryBrowser.module.css | 436 + .../src/client/DirectoryBrowser.tsx | 1208 ++ .../src/client/flow.ts | 45 + .../src/client/index.ts | 113 + .../src/css-modules.d.ts | 6 + .../ui-directory-picker-browse/src/index.ts | 10 + .../tsdown.config.ts | 5 + .../packages/client/ui-dockkit/package.json | 21 + .../ui-dockkit/src/components/DockSurface.tsx | 397 + .../ui-dockkit/src/components/FloatLayer.tsx | 256 + .../ui-dockkit/src/components/PaneTree.tsx | 62 + .../ui-dockkit/src/components/TabLayout.tsx | 246 + .../ui-dockkit/src/components/TabMenu.tsx | 150 + .../ui-dockkit/src/components/TabPanel.tsx | 533 + .../ui-dockkit/src/components/TabTitle.tsx | 45 + .../src/components/dockkit.module.css | 869 ++ .../ui-dockkit/src/components/measure.ts | 151 + .../ui-dockkit/src/components/pointer.ts | 146 + .../ui-dockkit/src/components/render.ts | 52 + .../client/ui-dockkit/src/contract/adapter.ts | 105 + .../client/ui-dockkit/src/contract/types.ts | 231 + .../client/ui-dockkit/src/css-modules.d.ts | 6 + .../ui-dockkit/src/engine/constraints.ts | 129 + .../ui-dockkit/src/engine/controller.ts | 338 + .../client/ui-dockkit/src/engine/geometry.ts | 229 + .../client/ui-dockkit/src/engine/initial.ts | 73 + .../ui-dockkit/src/engine/operations.ts | 571 + .../client/ui-dockkit/src/engine/planner.ts | 450 + .../client/ui-dockkit/src/engine/sequence.ts | 252 + .../client/ui-dockkit/src/engine/tree.ts | 346 + .../packages/client/ui-dockkit/src/index.ts | 136 + .../client/ui-dockkit/tsdown.config.ts | 3 + .../client/ui-input-trigger/package.json | 39 + .../src/client/MenuView.module.css | 281 + .../ui-input-trigger/src/client/MenuView.tsx | 272 + .../ui-input-trigger/src/client/contract.ts | 25 + .../ui-input-trigger/src/client/controller.ts | 693 + .../ui-input-trigger/src/client/index.ts | 127 + .../ui-input-trigger/src/client/locales.ts | 34 + .../ui-input-trigger/src/client/service.ts | 130 + .../ui-input-trigger/src/client/slots.ts | 35 + .../ui-input-trigger/src/core/contract.ts | 82 + .../ui-input-trigger/src/core/detect.ts | 76 + .../client/ui-input-trigger/src/core/menu.ts | 178 + .../ui-input-trigger/src/css-modules.d.ts | 6 + .../client/ui-input-trigger/src/index.ts | 9 + .../client/ui-input-trigger/src/types.ts | 260 + .../client/ui-input-trigger/tsdown.config.ts | 3 + .../packages/client/ui-layout/package.json | 35 + .../ui-layout/src/client/AppFrame.module.css | 363 + .../client/ui-layout/src/client/AppFrame.tsx | 438 + .../ui-layout/src/client/DocumentTitle.tsx | 36 + .../client/ui-layout/src/client/columns.ts | 71 + .../client/ui-layout/src/client/index.ts | 246 + .../client/ui-layout/src/client/service.ts | 106 + .../ui-layout/src/client/shortcut-locales.ts | 4 + .../client/ui-layout/src/client/stores.ts | 166 + .../ui-layout/src/client/theme-presenter.ts | 88 + .../client/ui-layout/src/css-modules.d.ts | 6 + .../packages/client/ui-layout/src/index.ts | 4 + .../client/ui-layout/tsdown.config.ts | 3 + .../client/ui-model-selection/package.json | 39 + .../src/client/ModelSelect.module.css | 331 + .../src/client/ModelSelect.tsx | 812 ++ .../ui-model-selection/src/client/catalog.ts | 120 + .../src/client/directory.ts | 242 + .../ui-model-selection/src/client/index.ts | 206 + .../ui-model-selection/src/client/locales.ts | 68 + .../src/client/provider-order.ts | 14 + .../ui-model-selection/src/client/service.ts | 106 + .../ui-model-selection/src/client/slots.ts | 26 + .../ui-model-selection/src/css-modules.d.ts | 6 + .../client/ui-model-selection/src/index.ts | 9 + .../ui-model-selection/tsdown.config.ts | 3 + .../client/ui-permission-presets/package.json | 39 + .../src/client/PermissionRow.module.css | 60 + .../src/client/PermissionRow.tsx | 143 + .../src/client/PermissionSelect.module.css | 102 + .../src/client/PermissionSelect.tsx | 235 + .../src/client/catalog.ts | 161 + .../ui-permission-presets/src/client/index.ts | 243 + .../src/client/locales.ts | 91 + .../src/client/presentation.ts | 55 + .../src/client/settings-store.ts | 221 + .../src/css-modules.d.ts | 4 + .../client/ui-permission-presets/src/index.ts | 9 + .../ui-permission-presets/tsdown.config.ts | 5 + .../packages/client/ui-plan/package.json | 41 + .../client/ui-plan/src/client/PlanCard.tsx | 136 + .../src/client/PlanModeControl.module.css | 71 + .../ui-plan/src/client/PlanModeControl.tsx | 81 + .../ui-plan/src/client/PlanPreview.module.css | 130 + .../client/ui-plan/src/client/PlanPreview.tsx | 94 + .../client/ui-plan/src/client/failure-line.ts | 23 + .../client/ui-plan/src/client/index.ts | 207 + .../client/ui-plan/src/client/locales.ts | 47 + .../ui-plan/src/client/plan-definition.ts | 41 + .../ui-plan/src/client/plan-resource.ts | 115 + .../client/ui-plan/src/client/plan.ts | 110 + .../ui-plan/src/client/review-preview.ts | 29 + .../client/ui-plan/src/client/review-store.ts | 25 + .../client/ui-plan/src/css-modules.d.ts | 4 + .../packages/client/ui-plan/src/index.ts | 11 + .../packages/client/ui-plan/tsdown.config.ts | 3 + .../ui-plugin-manager/generated/remote.d.ts | 21 + .../ui-plugin-manager/generated/remote.js | 28 + .../client/ui-plugin-manager/package.json | 41 + .../src/client/PluginManagerPage.module.css | 1488 ++ .../src/client/PluginManagerPage.tsx | 2245 +++ .../src/client/PluginRefreshToast.tsx | 37 + .../src/client/PluginsPanelIcon.tsx | 15 + .../src/client/config-ledger.ts | 86 + .../ui-plugin-manager/src/client/index.ts | 193 + .../ui-plugin-manager/src/client/locales.ts | 454 + .../src/client/manager-store.ts | 1515 ++ .../src/client/navigation-store.ts | 27 + .../src/client/presentation.ts | 222 + .../src/client/sanitize-install-input.ts | 14 + .../src/client/slot-contract.ts | 142 + .../ui-plugin-manager/src/css-modules.d.ts | 6 + .../client/ui-plugin-manager/src/index.ts | 102 + .../client/ui-plugin-manager/tsdown.config.ts | 5 + .../client/ui-primitives/package.json | 40 + .../ui-primitives/src/BrandWordmark.tsx | 112 + .../ui-primitives/src/Button.module.css | 77 + .../client/ui-primitives/src/Button.tsx | 42 + .../ui-primitives/src/Checkbox.module.css | 29 + .../client/ui-primitives/src/Checkbox.tsx | 44 + .../ui-primitives/src/CodeCard.module.css | 83 + .../client/ui-primitives/src/CodeFileIcon.tsx | 32 + .../client/ui-primitives/src/CodeToolbar.tsx | 99 + .../ui-primitives/src/ConfigField.module.css | 48 + .../client/ui-primitives/src/ConfigField.tsx | 90 + .../src/ConnectionIndicator.module.css | 131 + .../ui-primitives/src/ConnectionIndicator.tsx | 104 + .../ui-primitives/src/DiffBlock.module.css | 80 + .../client/ui-primitives/src/DiffBlock.tsx | 259 + .../src/DisclosureRow.module.css | 96 + .../ui-primitives/src/DisclosureRow.tsx | 122 + .../ui-primitives/src/FileTypeIcon.module.css | 50 + .../client/ui-primitives/src/FileTypeIcon.tsx | 351 + .../client/ui-primitives/src/FishLogo.tsx | 29 + .../client/ui-primitives/src/FoldToggle.tsx | 31 + .../ui-primitives/src/HoverCard.module.css | 97 + .../client/ui-primitives/src/HoverCard.tsx | 383 + .../src/ImageLightbox.module.css | 50 + .../ui-primitives/src/ImageLightbox.tsx | 78 + .../ui-primitives/src/ImagePreview.module.css | 32 + .../client/ui-primitives/src/ImagePreview.tsx | 66 + .../ui-primitives/src/InlineEditor.module.css | 23 + .../client/ui-primitives/src/InlineEditor.tsx | 81 + .../client/ui-primitives/src/Input.module.css | 38 + .../client/ui-primitives/src/Input.tsx | 27 + .../ui-primitives/src/JsonTree.module.css | 345 + .../client/ui-primitives/src/JsonTree.tsx | 913 ++ .../client/ui-primitives/src/LinkIcon.tsx | 192 + .../client/ui-primitives/src/Menu.module.css | 285 + .../client/ui-primitives/src/Menu.tsx | 685 + .../ui-primitives/src/MenuGroup.module.css | 37 + .../client/ui-primitives/src/MenuGroup.tsx | 137 + .../ui-primitives/src/MenuSurface.module.css | 49 + .../client/ui-primitives/src/MenuSurface.tsx | 66 + .../client/ui-primitives/src/Modal.module.css | 136 + .../client/ui-primitives/src/Modal.tsx | 111 + .../ui-primitives/src/PathLabel.module.css | 28 + .../client/ui-primitives/src/PathLabel.tsx | 53 + .../ui-primitives/src/PermissionIcon.tsx | 151 + .../client/ui-primitives/src/Pill.module.css | 28 + .../client/ui-primitives/src/Pill.tsx | 42 + .../ui-primitives/src/ReadBlock.module.css | 43 + .../client/ui-primitives/src/ReadBlock.tsx | 192 + .../ui-primitives/src/ReferenceIcon.tsx | 61 + .../src/RiskConfirmation.module.css | 74 + .../ui-primitives/src/RiskConfirmation.tsx | 85 + .../ui-primitives/src/SearchBlock.module.css | 112 + .../client/ui-primitives/src/SearchBlock.tsx | 308 + .../src/SegmentedControl.module.css | 77 + .../ui-primitives/src/SegmentedControl.tsx | 154 + .../src/SegmentedTabs.module.css | 55 + .../ui-primitives/src/SegmentedTabs.tsx | 103 + .../ui-primitives/src/ShortcutKeys.module.css | 52 + .../client/ui-primitives/src/ShortcutKeys.tsx | 35 + .../client/ui-primitives/src/SiteGlyph.tsx | 157 + .../ui-primitives/src/StateDot.module.css | 120 + .../client/ui-primitives/src/StateDot.tsx | 86 + .../ui-primitives/src/Switch.module.css | 50 + .../client/ui-primitives/src/Switch.tsx | 51 + .../client/ui-primitives/src/Tag.module.css | 57 + .../packages/client/ui-primitives/src/Tag.tsx | 50 + .../src/TerminalBlock.module.css | 212 + .../ui-primitives/src/TerminalBlock.tsx | 300 + .../ui-primitives/src/TextShimmer.module.css | 98 + .../client/ui-primitives/src/TextShimmer.tsx | 66 + .../client/ui-primitives/src/Toast.module.css | 101 + .../client/ui-primitives/src/Toast.tsx | 127 + .../ui-primitives/src/Tooltip.module.css | 75 + .../client/ui-primitives/src/Tooltip.tsx | 347 + .../ui-primitives/src/WebBlock.module.css | 141 + .../client/ui-primitives/src/WebBlock.tsx | 214 + .../packages/client/ui-primitives/src/ansi.ts | 482 + .../client/ui-primitives/src/clipboard.ts | 46 + .../src/code-file-icon-artwork.manifest.json | 67 + .../src/code-file-icon-artwork.ts | 84 + .../ui-primitives/src/code-file-types.ts | 340 + .../ui-primitives/src/code-highlighting.ts | 28 + .../client/ui-primitives/src/css-modules.d.ts | 6 + .../ui-primitives/src/darwin-desktop.ts | 11 + .../client/ui-primitives/src/file-size.ts | 16 + .../client/ui-primitives/src/focus.ts | 44 + .../ui-primitives/src/guide-artwork.tsx | 67 + .../client/ui-primitives/src/head-tail-cap.ts | 27 + .../client/ui-primitives/src/icons/index.tsx | 2883 ++++ .../client/ui-primitives/src/icons/props.ts | 8 + .../src/icons/shared-artwork.tsx | 185 + .../client/ui-primitives/src/index.ts | 164 + .../ui-primitives/src/input-modality.ts | 76 + .../ui-primitives/src/keyboard-composition.ts | 46 + .../src/markdown/CodeBlock.module.css | 145 + .../ui-primitives/src/markdown/CodeBlock.tsx | 286 + .../src/markdown/JsonBlock.module.css | 32 + .../ui-primitives/src/markdown/JsonBlock.tsx | 47 + .../src/markdown/MarkdownDelegate.tsx | 65 + .../src/markdown/MarkdownText.module.css | 474 + .../src/markdown/MarkdownText.tsx | 233 + .../src/markdown/cjkFriendlyStrong.ts | 87 + .../ui-primitives/src/markdown/file-link.ts | 36 + .../ui-primitives/src/markdown/highlight.ts | 616 + .../ui-primitives/src/markdown/incremental.ts | 367 + .../ui-primitives/src/markdown/katex.tsx | 86 + .../src/markdown/local-image-syntax.ts | 35 + .../src/markdown/mathCompatibility.ts | 367 + .../ui-primitives/src/markdown/parse.ts | 51 + .../ui-primitives/src/markdown/plain-text.ts | 121 + .../ui-primitives/src/markdown/render.tsx | 904 ++ .../src/markdown/useViewportHighlighting.ts | 74 + .../ui-primitives/src/overlay-top-margin.ts | 15 + .../ui-primitives/src/plugin-artwork.tsx | 209 + .../client/ui-primitives/src/pointer-grace.ts | 47 + .../client/ui-primitives/src/rank-by-name.ts | 98 + .../client/ui-primitives/src/relative-time.ts | 36 + .../src/settings-form/SettingsForm.module.css | 54 + .../src/settings-form/SettingsForm.tsx | 87 + .../src/settings-form/fields.module.css | 146 + .../src/settings-form/fields.tsx | 172 + .../src/settings-form/form-model.ts | 416 + .../ui-primitives/src/use-copy-feedback.ts | 33 + .../ui-primitives/src/useAnchoredMaxHeight.ts | 52 + .../ui-primitives/src/useAnchoredPosition.ts | 90 + .../src/useDismissOnOutsidePointer.ts | 38 + .../client/ui-primitives/src/useModalLayer.ts | 126 + .../ui-primitives/src/user-text.module.css | 46 + .../client/ui-primitives/src/user-text.tsx | 187 + .../client/ui-primitives/tsdown.config.ts | 3 + .../packages/client/ui-renderer/package.json | 32 + .../client/ui-renderer/src/client/app.tsx | 22 + .../client/ui-renderer/src/client/bind.ts | 27 + .../ui-renderer/src/client/bindings.tsx | 186 + .../client/ui-renderer/src/client/errors.ts | 4 + .../client/ui-renderer/src/client/index.ts | 114 + .../client/ui-renderer/src/client/registry.ts | 867 ++ .../ui-renderer/src/client/scoped-slots.tsx | 1551 ++ .../src/client/use-sync-external-store.d.ts | 14 + .../packages/client/ui-renderer/src/index.ts | 4 + .../client/ui-renderer/tsdown.config.ts | 3 + .../packages/client/ui-session/package.json | 36 + .../client/ui-session/src/client/index.ts | 736 + .../src/client/session-provider.tsx | 17 + .../packages/client/ui-session/src/index.ts | 4 + .../client/ui-session/tsdown.config.ts | 3 + .../client/ui-settings-general/package.json | 43 + .../src/client/CurrentVersionRow.module.css | 7 + .../src/client/CurrentVersionRow.tsx | 16 + .../client/DesktopUpdateIndicator.module.css | 69 + .../src/client/DesktopUpdateIndicator.tsx | 153 + .../src/client/DeveloperToolsRow.module.css | 20 + .../src/client/DeveloperToolsRow.tsx | 54 + .../src/client/GeneralSection.module.css | 12 + .../src/client/GeneralSection.tsx | 16 + .../client/SettingsDocumentAction.module.css | 16 + .../src/client/SettingsDocumentAction.tsx | 62 + .../src/client/SettingsRoot.module.css | 298 + .../src/client/SettingsRoot.tsx | 350 + .../src/client/chrome.module.css | 7 + .../ui-settings-general/src/client/chrome.tsx | 50 + .../src/client/desktop-update-source.ts | 58 + .../ui-settings-general/src/client/index.ts | 320 + .../ui-settings-general/src/client/locales.ts | 86 + .../src/client/settings-document-store.ts | 108 + .../src/client/shell-contract.ts | 82 + .../src/client/shell-store.ts | 36 + .../ui-settings-general/src/css-modules.d.ts | 6 + .../client/ui-settings-general/src/index.ts | 26 + .../client/ui-settings-general/src/types.ts | 43 + .../ui-settings-general/tsdown.config.ts | 3 + .../packages/client/ui-settings/package.json | 32 + .../src/client/config-form-types.ts | 77 + .../ui-settings/src/client/config-form.ts | 363 + .../ui-settings/src/client/contract/slots.ts | 155 + .../ui-settings/src/client/developer-tools.ts | 46 + .../client/ui-settings/src/client/index.ts | 67 + .../client/ui-settings/src/client/schema.ts | 168 + .../ui-settings/src/client/settings-mirror.ts | 211 + .../client/ui-settings/src/css-modules.d.ts | 6 + .../src/developer-tools-settings.ts | 19 + .../packages/client/ui-settings/src/index.ts | 27 + .../client/ui-settings/tsdown.config.ts | 3 + .../packages/client/ui-shortcuts/package.json | 36 + .../client/ui-shortcuts/src/client/Editor.tsx | 408 + .../client/ui-shortcuts/src/client/Icons.tsx | 48 + .../src/client/Reference.module.css | 431 + .../ui-shortcuts/src/client/Reference.tsx | 459 + .../ui-shortcuts/src/client/feedback.ts | 52 + .../client/ui-shortcuts/src/client/fixed.ts | 32 + .../client/ui-shortcuts/src/client/index.ts | 108 + .../client/ui-shortcuts/src/client/locales.ts | 130 + .../client/ui-shortcuts/src/client/store.ts | 32 + .../client/ui-shortcuts/src/css-modules.d.ts | 6 + .../packages/client/ui-shortcuts/src/index.ts | 4 + .../client/ui-shortcuts/tsdown.config.ts | 3 + .../client/ui-sidebar-right/package.json | 42 + .../src/client/contract/params.ts | 51 + .../src/client/contract/seed.ts | 48 + .../src/client/contract/slots.ts | 237 + .../ui-sidebar-right/src/client/focus.ts | 189 + .../ui-sidebar-right/src/client/index.ts | 390 + .../ui-sidebar-right/src/client/labels.ts | 48 + .../ui-sidebar-right/src/client/locales.ts | 86 + .../src/client/persistence.ts | 169 + .../ui-sidebar-right/src/client/service.ts | 845 ++ .../src/client/session-view.ts | 93 + .../src/client/session-views.ts | 99 + .../src/client/shell/ExpandButton.module.css | 17 + .../src/client/shell/ExpandButton.tsx | 66 + .../src/client/shell/RightbarRoot.tsx | 69 + .../src/client/shell/SidebarRight.module.css | 177 + .../src/client/shell/SidebarRight.tsx | 646 + .../src/client/shell/close-focus.ts | 49 + .../ui-sidebar-right/src/client/shortcuts.ts | 171 + .../ui-sidebar-right/src/client/stores.ts | 613 + .../ui-sidebar-right/src/client/tab-domain.ts | 243 + .../ui-sidebar-right/src/client/tab-info.ts | 89 + .../src/client/tab-inventory.ts | 77 + .../src/client/tab-registry.ts | 450 + .../client/tabs/guide/GuideBody.module.css | 126 + .../src/client/tabs/guide/GuideBody.tsx | 154 + .../src/client/tabs/guide/GuideTitle.tsx | 88 + .../src/client/tabs/guide/definition.ts | 27 + .../ui-sidebar-right/src/css-modules.d.ts | 6 + .../client/ui-sidebar-right/src/index.ts | 4 + .../client/ui-sidebar-right/tsdown.config.ts | 3 + .../packages/client/ui-sidebar/package.json | 40 + .../client/HeaderLeadingControls.module.css | 29 + .../src/client/HeaderLeadingControls.tsx | 65 + .../src/client/SidebarRoot.module.css | 636 + .../ui-sidebar/src/client/SidebarRoot.tsx | 378 + .../ui-sidebar/src/client/contract/slots.ts | 156 + .../client/ui-sidebar/src/client/index.ts | 137 + .../client/ui-sidebar/src/client/locales.ts | 22 + .../client/ui-sidebar/src/css-modules.d.ts | 6 + .../packages/client/ui-sidebar/src/index.ts | 4 + .../client/ui-sidebar/tsdown.config.ts | 3 + .../packages/client/ui-slots/package.json | 16 + .../packages/client/ui-slots/src/index.ts | 1812 +++ .../packages/client/ui-slots/src/renderer.ts | 292 + .../packages/client/ui-slots/src/store.ts | 17 + .../packages/client/ui-slots/tsdown.config.ts | 3 + .../packages/client/ui-subagent/package.json | 40 + .../client/SubagentHeaderLineage.module.css | 367 + .../src/client/SubagentHeaderLineage.tsx | 900 ++ .../SubagentReadOnlyComposer.module.css | 20 + .../src/client/SubagentReadOnlyComposer.tsx | 39 + .../client/ui-subagent/src/client/index.ts | 118 + .../client/ui-subagent/src/client/locales.ts | 94 + .../sidebar-chat/SidebarChat.module.css | 7 + .../src/client/sidebar-chat/index.tsx | 253 + .../client/ui-subagent/src/css-modules.d.ts | 6 + .../packages/client/ui-subagent/src/index.ts | 9 + .../client/ui-subagent/tsdown.config.ts | 3 + .../packages/client/ui-theme/package.json | 40 + .../client/ui-theme/src/boot-theme.ts | 53 + .../src/client/AppearanceRow.module.css | 57 + .../ui-theme/src/client/AppearanceRow.tsx | 72 + .../src/client/FontSizeRow.module.css | 111 + .../ui-theme/src/client/FontSizeRow.tsx | 77 + .../client/ui-theme/src/client/index.ts | 605 + .../client/ui-theme/src/client/locales.ts | 30 + .../ui-theme/src/client/settings-store.ts | 70 + .../client/ui-theme/src/client/styles.ts | 42 + .../client/ui-theme/src/css-modules.d.ts | 11 + .../packages/client/ui-theme/src/index.ts | 70 + .../ui-theme/src/styles/Montserrat-OFL.txt | 93 + .../client/ui-theme/src/styles/base.css | 34 + .../client/ui-theme/src/styles/brand-font.css | 24 + .../ui-theme/src/styles/corner-shape.css | 26 + .../ui-theme/src/styles/design-platform.css | 432 + .../client/ui-theme/src/styles/focus.css | 21 + .../src/styles/gradient-shadow-text.css | 291 + .../src/styles/montserrat-light.woff2 | Bin 0 -> 61548 bytes .../src/styles/montserrat-medium.woff2 | Bin 0 -> 62148 bytes .../src/styles/montserrat-regular.woff2 | Bin 0 -> 62008 bytes .../client/ui-theme/src/styles/onboarding.css | 29 + .../client/ui-theme/src/styles/scrollbar.css | 95 + .../client/ui-theme/src/styles/shiki.css | 31 + .../client/ui-theme/src/theme-settings.ts | 58 + .../packages/client/ui-theme/tsdown.config.ts | 12 + .../packages/client/ui-tool/package.json | 37 + .../client/ui-tool/src/client/apply.ts | 58 + .../ui-tool/src/client/contract/slots.ts | 171 + .../client/ui-tool/src/client/index.ts | 13 + .../client/ui-tool/src/client/locale.ts | 2 + .../src/client/tool/ToolCallTree.module.css | 12 + .../ui-tool/src/client/tool/ToolCallTree.tsx | 166 + .../components/AskQuestionCard.module.css | 67 + .../tool/components/AskQuestionCard.tsx | 46 + .../components/QuestionToolRow.module.css | 29 + .../tool/components/QuestionToolRow.tsx | 123 + .../tool/components/ToolDetails.module.css | 289 + .../client/tool/components/ToolDetails.tsx | 206 + .../client/tool/components/ToolRow.module.css | 320 + .../src/client/tool/components/ToolRow.tsx | 467 + .../tool/models/ask-question-card-model.ts | 25 + .../client/tool/models/auto-review-denial.ts | 36 + .../tool/models/control-details-model.ts | 404 + .../client/tool/models/detail-model-shared.ts | 242 + .../client/tool/models/details-card-model.ts | 252 + .../src/client/tool/models/diff-card-model.ts | 111 + .../client/tool/models/image-card-model.ts | 253 + .../tool/models/inspection-details-model.ts | 351 + .../client/tool/models/primitive-labels.ts | 109 + .../src/client/tool/models/raw-tool-call.ts | 52 + .../src/client/tool/models/read-card-model.ts | 135 + .../client/tool/models/search-card-model.ts | 110 + .../client/tool/models/terminal-card-model.ts | 348 + .../src/client/tool/models/todo-diff-model.ts | 107 + .../src/client/tool/models/todo-history.ts | 101 + .../src/client/tool/models/tool-call-model.ts | 373 + .../src/client/tool/models/web-card-model.ts | 109 + .../client/tool/toolviews/GenericToolCard.tsx | 86 + .../tool/toolviews/ask-question-row.tsx | 437 + .../tool/toolviews/bash-sample.module.css | 239 + .../src/client/tool/toolviews/bash-sample.tsx | 209 + .../src/client/tool/toolviews/details-row.tsx | 252 + .../tool/toolviews/file-mutation-row.tsx | 89 + .../src/client/tool/toolviews/plan-summary.ts | 60 + .../client/tool/toolviews/read-family-row.tsx | 64 + .../client/tool/toolviews/read-image-row.tsx | 65 + .../src/client/tool/toolviews/read-row.tsx | 31 + .../src/client/tool/toolviews/search-row.tsx | 57 + .../src/client/tool/toolviews/todo-row.tsx | 118 + .../src/client/tool/toolviews/web-row.tsx | 71 + .../client/ui-tool/src/css-modules.d.ts | 4 + .../packages/client/ui-tool/src/index.ts | 4 + .../packages/client/ui-tool/tsdown.config.ts | 3 + .../client/ui-user-questions/package.json | 41 + .../src/client/PlanReviewPanel.module.css | 109 + .../src/client/PlanReviewPanel.tsx | 112 + .../src/client/QuestionComposer.module.css | 487 + .../src/client/QuestionComposer.tsx | 771 + .../src/client/QuestionReplyView.module.css | 166 + .../src/client/QuestionReplyView.tsx | 149 + .../src/client/contract/slots.ts | 624 + .../src/client/draft-store.ts | 67 + .../ui-user-questions/src/client/index.ts | 478 + .../ui-user-questions/src/client/locales.ts | 74 + .../src/client/question-reply.ts | 207 + .../ui-user-questions/src/css-modules.d.ts | 4 + .../client/ui-user-questions/src/index.ts | 11 + .../client/ui-user-questions/tsdown.config.ts | 3 + .../packages/client/ui-workspace/package.json | 47 + .../src/client/WorkspacePicker.module.css | 20 + .../src/client/WorkspacePicker.tsx | 295 + .../ui-workspace/src/client/contract/slots.ts | 520 + .../client/ui-workspace/src/client/index.ts | 493 + .../client/ui-workspace/src/client/locales.ts | 249 + .../ui-workspace/src/client/navigation.ts | 547 + .../ui-workspace/src/client/pin-order.ts | 54 + .../src/client/rows/AnimatedRows.module.css | 7 + .../src/client/rows/AnimatedRows.tsx | 237 + .../src/client/rows/Rows.module.css | 435 + .../ui-workspace/src/client/rows/Rows.tsx | 830 ++ .../client/rows/WorkspaceBrowser.module.css | 631 + .../src/client/rows/WorkspaceBrowser.tsx | 1810 +++ .../client/session-actions/ArchiveSession.tsx | 232 + .../client/session-actions/ForkSession.tsx | 32 + .../src/client/session-actions/PinSession.tsx | 86 + .../client/session-actions/RenameSession.tsx | 167 + .../client/session-actions/RowActionToast.tsx | 110 + .../src/client/session-actions/derived.ts | 29 + .../ui-workspace/src/client/shortcuts.ts | 237 + .../client/ui-workspace/src/client/stores.ts | 155 + .../client/ui-workspace/src/client/tree.ts | 696 + .../client/ui-workspace/src/css-modules.d.ts | 6 + .../packages/client/ui-workspace/src/index.ts | 9 + .../client/ui-workspace/tsdown.config.ts | 3 + apps/web-ui/packages/client/web/package.json | 29 + .../client/web/src/apply-injections.ts | 55 + apps/web-ui/packages/client/web/src/base.css | 132 + .../packages/client/web/src/boot-client.ts | 101 + .../client/web/src/boot-page.module.css | 105 + .../packages/client/web/src/boot-page.ts | 103 + apps/web-ui/packages/client/web/src/boot.ts | 133 + .../packages/client/web/src/css-modules.d.ts | 6 + apps/web-ui/packages/client/web/src/index.ts | 12 + .../packages/client/web/src/loader-status.ts | 36 + apps/web-ui/packages/client/web/src/mount.ts | 24 + .../packages/client/web/src/platform.ts | 24 + apps/web-ui/packages/client/web/src/seed.ts | 39 + .../client/web/src/window-drag/recall.ts | 241 + .../client/web/src/window-drag/regions.ts | 111 + .../packages/client/web/tsdown.config.ts | 6 + .../context/file-reference/package.json | 24 + .../context/file-reference/src/grammar.ts | 55 + .../context/file-reference/src/index.ts | 46 + .../context/file-reference/src/types.ts | 13 + .../session-reference/generated/remote.d.ts | 26 + .../session-reference/generated/remote.js | 67 + .../context/session-reference/package.json | 30 + .../context/session-reference/src/config.ts | 43 + .../context/session-reference/src/index.ts | 554 + .../session-reference/src/projection.ts | 208 + .../session-reference/src/serialization.ts | 13 + .../context/session-reference/src/spill.ts | 95 + .../context/session-reference/src/types.ts | 88 + .../context/session-reference/src/uri.ts | 110 + .../web-ui/packages/core/session/package.json | 30 + apps/web-ui/packages/core/session/src/fork.ts | 34 + .../web-ui/packages/core/session/src/index.ts | 1470 ++ .../core/session/src/known-event-types.ts | 85 + .../packages/core/session/src/preparation.ts | 49 + .../packages/core/session/src/repair.ts | 237 + .../core/session/src/request-header.ts | 73 + .../packages/core/session/src/seq-ranges.ts | 79 + .../packages/core/session/src/surface.ts | 799 + .../packages/core/session/src/tool-history.ts | 84 + .../web-ui/packages/core/session/src/types.ts | 522 + .../packages/core/session/tsdown.config.ts | 15 + .../office-to-pdf/generated/remote.d.ts | 29 + .../office-to-pdf/generated/remote.js | 132 + .../document/office-to-pdf/package.json | 30 + .../document/office-to-pdf/src/errors.ts | 19 + .../document/office-to-pdf/src/identity.ts | 35 + .../document/office-to-pdf/src/index.ts | 433 + .../document/office-to-pdf/src/output.ts | 55 + .../document/office-to-pdf/src/queue.ts | 316 + .../document/office-to-pdf/src/types.ts | 69 + .../cordis-host-runner/generated/remote.d.ts | 56 + .../cordis-host-runner/generated/remote.js | 819 ++ .../cordis-host-runner/package.json | 30 + .../cordis-host-runner/src/guard.ts | 1016 ++ .../cordis-host-runner/src/index.ts | 1511 ++ .../src/inspect-registry.ts | 349 + .../cordis-host-runner/src/lifecycle.ts | 57 + .../cordis-host-runner/src/registry.ts | 279 + .../cordis-host-runner/src/sandbox.ts | 274 + .../cordis-host-runner/src/types.ts | 405 + .../command-feedback/generated/remote.d.ts | 22 + .../command-feedback/generated/remote.js | 55 + .../feedback/command-feedback/package.json | 30 + .../feedback/command-feedback/src/index.ts | 130 + .../feedback/command-feedback/src/types.ts | 69 + .../message-feedback/generated/remote.d.ts | 26 + .../message-feedback/generated/remote.js | 190 + .../feedback/message-feedback/package.json | 30 + .../feedback/message-feedback/src/index.ts | 372 + .../feedback/message-feedback/src/types.ts | 177 + .../packages/goal/goal/generated/remote.d.ts | 44 + .../packages/goal/goal/generated/remote.js | 433 + apps/web-ui/packages/goal/goal/package.json | 34 + apps/web-ui/packages/goal/goal/src/client.ts | 10 + apps/web-ui/packages/goal/goal/src/domain.ts | 112 + apps/web-ui/packages/goal/goal/src/fold.ts | 399 + apps/web-ui/packages/goal/goal/src/index.ts | 718 + apps/web-ui/packages/goal/goal/src/runtime.ts | 30 + apps/web-ui/packages/goal/goal/src/types.ts | 148 + .../packages/goal/goal/tsdown.config.ts | 15 + .../plugin-inventory/generated/remote.d.ts | 22 + .../host/plugin-inventory/generated/remote.js | 69 + .../host/plugin-inventory/package.json | 30 + .../host/plugin-inventory/src/index.ts | 115 + .../host/plugin-inventory/src/types.ts | 69 + .../commands/generated/remote.d.ts | 29 + .../interaction/commands/generated/remote.js | 131 + .../interaction/commands/package.json | 34 + .../interaction/commands/src/brand.ts | 40 + .../interaction/commands/src/index.ts | 594 + .../interaction/commands/src/types.ts | 119 + .../permission-presets/generated/remote.d.ts | 22 + .../permission-presets/generated/remote.js | 40 + .../permission-presets/package.json | 34 + .../permission-presets/src/client.ts | 10 + .../permission-presets/src/index.ts | 542 + .../permission-presets/src/types.ts | 63 + .../user-questions/generated/remote.d.ts | 31 + .../user-questions/generated/remote.js | 125 + .../interaction/user-questions/package.json | 30 + .../interaction/user-questions/src/index.ts | 409 + .../user-questions/src/projection.ts | 420 + .../user-questions/src/timed-wait.ts | 94 + .../interaction/user-questions/src/types.ts | 163 + .../packages/llm/llm/generated/remote.d.ts | 26 + .../packages/llm/llm/generated/remote.js | 109 + apps/web-ui/packages/llm/llm/package.json | 45 + .../packages/llm/llm/src/adapter-failure.ts | 113 + apps/web-ui/packages/llm/llm/src/api-key.ts | 41 + apps/web-ui/packages/llm/llm/src/assembler.ts | 230 + .../packages/llm/llm/src/assistant-stream.ts | 600 + .../packages/llm/llm/src/attribution.ts | 66 + apps/web-ui/packages/llm/llm/src/brand.ts | 76 + .../packages/llm/llm/src/call-config.ts | 79 + apps/web-ui/packages/llm/llm/src/content.ts | 532 + apps/web-ui/packages/llm/llm/src/error.ts | 187 + apps/web-ui/packages/llm/llm/src/index.ts | 1310 ++ apps/web-ui/packages/llm/llm/src/message.ts | 309 + .../packages/llm/llm/src/retry-policy.ts | 209 + apps/web-ui/packages/llm/llm/src/types.ts | 559 + .../packages/llm/token-meter/package.json | 26 + .../token-meter/src/breakdown-projection.ts | 105 + .../packages/llm/token-meter/src/client.ts | 9 + .../packages/llm/token-meter/src/estimate.ts | 103 + .../packages/llm/token-meter/src/index.ts | 356 + .../llm/token-meter/src/projection.ts | 77 + .../llm/token-meter/src/route-pricing.ts | 76 + .../llm/token-meter/src/surface-fold.ts | 142 + .../llm/token-meter/src/surface-projection.ts | 96 + .../llm/token-meter/src/turn-usage.ts | 310 + .../packages/llm/token-meter/src/types.ts | 58 + .../llm/token-meter/src/usage-projection.ts | 235 + .../generated/remote.d.ts | 30 + .../agent-preset-registry/generated/remote.js | 118 + .../preset/agent-preset-registry/package.json | 34 + .../src/composition-inventory.ts | 208 + .../agent-preset-registry/src/definition.ts | 42 + .../agent-preset-registry/src/display.ts | 77 + .../preset/agent-preset-registry/src/index.ts | 479 + .../preset/agent-preset-registry/src/mount.ts | 228 + .../agent-preset-registry/src/preset.ts | 18 + .../agent-preset-registry/src/session.ts | 43 + .../preset/agent-preset-registry/src/types.ts | 75 + .../schedule/schedule/generated/remote.d.ts | 30 + .../schedule/schedule/generated/remote.js | 480 + .../packages/schedule/schedule/package.json | 31 + .../packages/schedule/schedule/src/client.ts | 2 + .../schedule/schedule/src/delivery-history.ts | 96 + .../packages/schedule/schedule/src/domain.ts | 2144 +++ .../packages/schedule/schedule/src/index.ts | 551 + .../packages/schedule/schedule/src/runtime.ts | 207 + .../packages/schedule/schedule/src/storage.ts | 84 + .../packages/schedule/schedule/src/types.ts | 479 + .../packages/schedule/schedule/src/update.ts | 225 + .../schedule/schedule/tsdown.config.ts | 15 + .../packages/spill/spill-policy/package.json | 22 + .../packages/spill/spill-policy/src/index.ts | 221 + .../packages/spill/spill-policy/src/notice.ts | 72 + .../spill/spill-policy/src/retention.ts | 114 + .../packages/spill/spill-policy/src/types.ts | 26 + .../subagent/subagent/generated/remote.d.ts | 25 + .../subagent/subagent/generated/remote.js | 114 + .../packages/subagent/subagent/package.json | 34 + .../subagent/src/archive-admission.ts | 97 + .../subagent/subagent/src/assistant-output.ts | 77 + .../packages/subagent/subagent/src/catalog.ts | 188 + .../subagent/subagent/src/child-agent.ts | 298 + .../packages/subagent/subagent/src/client.ts | 12 + .../subagent/src/continuation-activation.ts | 922 ++ .../subagent/src/continuation-messages.ts | 158 + .../subagent/subagent/src/continuation.ts | 555 + .../subagent/subagent/src/control-types.ts | 135 + .../packages/subagent/subagent/src/control.ts | 123 + .../packages/subagent/subagent/src/depth.ts | 55 + .../subagent/subagent/src/descriptor.ts | 327 + .../packages/subagent/subagent/src/error.ts | 15 + .../packages/subagent/subagent/src/inbox.ts | 70 + .../packages/subagent/subagent/src/index.ts | 700 + .../subagent/subagent/src/internal.ts | 114 + .../subagent/subagent/src/lifecycle.ts | 282 + .../subagent/subagent/src/list-children.ts | 141 + .../subagent/subagent/src/out-of-process.ts | 269 + .../subagent/subagent/src/projection-types.ts | 83 + .../subagent/subagent/src/projection.ts | 205 + .../subagent/subagent/src/run-settlement.ts | 75 + .../packages/subagent/subagent/src/types.ts | 390 + .../packages/typert/protocol/package.json | 22 + .../packages/typert/protocol/src/index.ts | 367 + .../typert/protocol/src/json-value.ts | 57 + .../typert/protocol/src/owned-value.ts | 46 + .../typert/protocol/src/remote-error.ts | 52 + .../packages/typert/protocol/src/types.ts | 728 + .../packages/typert/registry/package.json | 34 + .../typert/registry/src/client/index.ts | 15 + .../packages/typert/registry/src/index.ts | 27 + .../packages/typert/registry/src/service.ts | 819 ++ .../packages/typert/registry/src/types.ts | 118 + .../packages/typert/registry/tsdown.config.ts | 3 + apps/web-ui/packages/util/brand/package.json | 16 + apps/web-ui/packages/util/brand/src/index.ts | 39 + .../packages/util/code-language/package.json | 16 + .../packages/util/code-language/src/index.ts | 241 + apps/web-ui/packages/util/crypto/package.json | 16 + apps/web-ui/packages/util/crypto/src/index.ts | 41 + apps/web-ui/packages/util/deque/package.json | 16 + apps/web-ui/packages/util/deque/src/index.ts | 95 + .../util/output-retention/package.json | 16 + .../util/output-retention/src/index.ts | 455 + apps/web-ui/packages/util/values/package.json | 16 + apps/web-ui/packages/util/values/src/index.ts | 341 + .../packages/util/values/src/partial-json.ts | 795 + .../packages/util/workspace-path/package.json | 16 + .../util/workspace-path/src/file-address.ts | 121 + .../packages/util/workspace-path/src/index.ts | 131 + apps/web-ui/scripts/build.ts | 31 + apps/web-ui/scripts/bundle-input-isolation.ts | 109 + .../scripts/client-build-environment.ts | 436 + .../scripts/web-product-bundle-isolation.ts | 230 + apps/web-ui/tsconfig.base.json | 13 + apps/web-ui/tsconfig.json | 4 + apps/web-ui/vendor/cordis/LICENSE | 21 + apps/web-ui/vendor/cordis/package.json | 18 + apps/web-ui/vendor/cordis/src/context.ts | 149 + apps/web-ui/vendor/cordis/src/events.ts | 405 + apps/web-ui/vendor/cordis/src/fiber.ts | 774 + apps/web-ui/vendor/cordis/src/index.ts | 16 + apps/web-ui/vendor/cordis/src/logger.ts | 287 + apps/web-ui/vendor/cordis/src/reflect.ts | 448 + apps/web-ui/vendor/cordis/src/registry.ts | 349 + apps/web-ui/vendor/cordis/src/service.ts | 122 + apps/web-ui/vendor/cordis/src/utils.ts | 304 + apps/web-ui/vendor/cosmokit/LICENSE | 21 + apps/web-ui/vendor/cosmokit/package.json | 16 + apps/web-ui/vendor/cosmokit/src/array.ts | 42 + apps/web-ui/vendor/cosmokit/src/index.ts | 12 + apps/web-ui/vendor/cosmokit/src/misc.ts | 95 + apps/web-ui/vendor/cosmokit/src/string.ts | 167 + apps/web-ui/vendor/cosmokit/src/time.ts | 98 + apps/web-ui/vendor/cosmokit/src/types.ts | 191 + apps/web-ui/vendor/cosmokit/src/volatile.ts | 102 + apps/web-ui/vendor/loader/LICENSE | 21 + apps/web-ui/vendor/loader/package.json | 19 + apps/web-ui/vendor/loader/src/config/diff.ts | 57 + apps/web-ui/vendor/loader/src/config/entry.ts | 262 + apps/web-ui/vendor/loader/src/config/group.ts | 100 + .../vendor/loader/src/config/isolate.ts | 179 + apps/web-ui/vendor/loader/src/config/tree.ts | 142 + apps/web-ui/vendor/loader/src/config/utils.ts | 36 + apps/web-ui/vendor/loader/src/index.ts | 225 + apps/web-ui/vendor/loader/src/internal.ts | 186 + apps/web-ui/vendor/loader/tsdown.config.ts | 14 + apps/web-ui/vendor/schemastery/LICENSE | 21 + apps/web-ui/vendor/schemastery/package.json | 19 + apps/web-ui/vendor/schemastery/src/index.ts | 1088 ++ .../vendor/schemastery/tsdown.config.ts | 17 + docs/index.md | 6 + docs/product/standalone-web.md | 44 + package-lock.json | 12176 ++++++++++++---- package.json | 15 +- packages/adapters/codex/LICENSE.upstream | 21 + packages/adapters/codex/README.md | 7 + packages/adapters/codex/assets/icon.svg | 1 + packages/adapters/codex/manifest.json | 10 + packages/adapters/codex/package.json | 23 + packages/adapters/codex/src/items.ts | 322 + packages/adapters/codex/src/plugin.ts | 324 + packages/adapters/codex/src/rpc.ts | 185 + packages/adapters/codex/src/session.ts | 834 ++ packages/adapters/codex/test/items.test.ts | 137 + packages/adapters/codex/tsconfig.json | 15 + packages/web-server/LICENSE.upstream | 21 + packages/web-server/README.md | 11 + .../fixtures/settings-namespaces.json | 338 + packages/web-server/package.json | 31 + packages/web-server/scripts/pack.ts | 186 + packages/web-server/scripts/preview.ts | 46 + packages/web-server/src/auth.ts | 103 + packages/web-server/src/check-plugins.ts | 14 + packages/web-server/src/harnesses.ts | 354 + packages/web-server/src/main.ts | 295 + packages/web-server/src/misc.ts | 100 + packages/web-server/src/packaged-adapters.ts | 62 + packages/web-server/src/plugins.ts | 58 + packages/web-server/src/preview-launch.ts | 51 + packages/web-server/src/projector.ts | 761 + packages/web-server/src/push.ts | 143 + packages/web-server/src/qrcode-terminal.d.ts | 6 + packages/web-server/src/session-log.ts | 349 + packages/web-server/src/sessions.ts | 1782 +++ packages/web-server/src/settings.ts | 122 + packages/web-server/src/store.ts | 74 + packages/web-server/src/transport.ts | 306 + packages/web-server/src/web-assets.ts | 477 + packages/web-server/src/web-push.d.ts | 12 + packages/web-server/src/workspaces.ts | 235 + packages/web-server/test/browser/web.test.ts | 138 + .../web-server/test/distribution/pack.test.ts | 135 + .../test/distribution/source-plugins.test.ts | 53 + .../test/fake-harness/fake/manifest.json | 8 + .../test/fake-harness/fake/plugin.ts | 230 + .../claude-question-spanning-tool.jsonl | 14 + .../web-server/test/packaged-adapters.test.ts | 76 + .../web-server/test/preview-launch.test.ts | 82 + packages/web-server/test/projector.test.ts | 395 + .../test/sessions.integration.test.ts | 588 + packages/web-server/test/support/defined.ts | 13 + packages/web-server/test/support/server.ts | 89 + packages/web-server/tsconfig.json | 19 + tests/tsconfig.json | 1 + tests/vitest.config.js | 9 +- tsconfig.json | 2 + 1313 files changed, 232034 insertions(+), 3188 deletions(-) create mode 100644 apps/web-ui/LICENSE create mode 100644 apps/web-ui/README.md create mode 100644 apps/web-ui/THIRD_PARTY_NOTICES.md create mode 100644 apps/web-ui/apps/web/.npmignore create mode 100644 apps/web-ui/apps/web/index.html create mode 100644 apps/web-ui/apps/web/package.json create mode 100644 apps/web-ui/apps/web/product-isolation.ts create mode 100644 apps/web-ui/apps/web/public/apple-touch-icon.png create mode 100644 apps/web-ui/apps/web/public/favicon-dark.svg create mode 100644 apps/web-ui/apps/web/public/favicon.svg create mode 100644 apps/web-ui/apps/web/public/icon-192.png create mode 100644 apps/web-ui/apps/web/public/icon-512.png create mode 100644 apps/web-ui/apps/web/public/manifest.webmanifest create mode 100644 apps/web-ui/apps/web/public/sw.js create mode 100644 apps/web-ui/apps/web/src/main.ts create mode 100644 apps/web-ui/apps/web/src/node-module-stub.ts create mode 100644 apps/web-ui/apps/web/src/preview.ts create mode 100644 apps/web-ui/apps/web/src/vite-env.d.ts create mode 100644 apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts create mode 100644 apps/web-ui/apps/web/tsconfig.json create mode 100644 apps/web-ui/apps/web/vite.config.ts create mode 100644 apps/web-ui/import-manifest.json create mode 100644 apps/web-ui/package.json create mode 100644 apps/web-ui/packages/api/account-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/account-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/account-controller/package.json create mode 100644 apps/web-ui/packages/api/account-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/account-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/gateway/package.json create mode 100644 apps/web-ui/packages/api/gateway/src/client/index.ts create mode 100644 apps/web-ui/packages/api/gateway/src/client/journal-stream.ts create mode 100644 apps/web-ui/packages/api/gateway/src/client/remote-events.ts create mode 100644 apps/web-ui/packages/api/gateway/src/client/remote-stream.ts create mode 100644 apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts create mode 100644 apps/web-ui/packages/api/gateway/src/client/stream-client.ts create mode 100644 apps/web-ui/packages/api/gateway/src/index.ts create mode 100644 apps/web-ui/packages/api/gateway/src/remote-error-codes.ts create mode 100644 apps/web-ui/packages/api/gateway/src/stream-protocol.ts create mode 100644 apps/web-ui/packages/api/gateway/src/stream-server.ts create mode 100644 apps/web-ui/packages/api/gateway/src/types.ts create mode 100644 apps/web-ui/packages/api/gateway/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/job-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/job-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/job-controller/package.json create mode 100644 apps/web-ui/packages/api/job-controller/src/client/index.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/client/model.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/client/service.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/observe.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/rows.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/job-controller/src/wake.ts create mode 100644 apps/web-ui/packages/api/job-controller/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/remotes/package.json create mode 100644 apps/web-ui/packages/api/remotes/src/client/index.ts create mode 100644 apps/web-ui/packages/api/remotes/src/index.ts create mode 100644 apps/web-ui/packages/api/remotes/src/remote-events.ts create mode 100644 apps/web-ui/packages/api/remotes/src/types.ts create mode 100644 apps/web-ui/packages/api/remotes/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/session-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/session-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/session-controller/package.json create mode 100644 apps/web-ui/packages/api/session-controller/src/agent.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/assistant-stream.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/catalog.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/contract/events.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/contract/session.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/index.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/scope.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/time-zone.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/client/transport.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/commands.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/control.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/file-references.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/history.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/list.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/media-references.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/remote-events.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/skill-catalog.ts create mode 100644 apps/web-ui/packages/api/session-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/session-controller/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/settings-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/settings-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/settings-controller/package.json create mode 100644 apps/web-ui/packages/api/settings-controller/src/credentials.ts create mode 100644 apps/web-ui/packages/api/settings-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/settings-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/terminal-controller/package.json create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/index.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/model.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/retention.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/retention.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/shells.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/stream.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/terminal.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/terminal-controller/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/generated/remote.js create mode 100644 apps/web-ui/packages/api/workspace-controller/package.json create mode 100644 apps/web-ui/packages/api/workspace-controller/src/client/index.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/client/model.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/client/service.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/commands.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/default-directory.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/feed.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/index.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/src/types.ts create mode 100644 apps/web-ui/packages/api/workspace-controller/tsdown.config.ts create mode 100644 apps/web-ui/packages/api/workspace-files/generated/remote.d.ts create mode 100644 apps/web-ui/packages/api/workspace-files/generated/remote.js create mode 100644 apps/web-ui/packages/api/workspace-files/package.json create mode 100644 apps/web-ui/packages/api/workspace-files/src/changes.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/client/index.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/client/provider.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/client/remote.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/client/types.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/index.ts create mode 100644 apps/web-ui/packages/api/workspace-files/src/types.ts create mode 100644 apps/web-ui/packages/api/workspace-files/tsdown.config.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/generated/remote.js create mode 100644 apps/web-ui/packages/boot/plugin-manager/package.json create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/failure.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/index.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/operations.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/patch.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/registry.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/tools.ts create mode 100644 apps/web-ui/packages/boot/plugin-manager/src/types.ts create mode 100644 apps/web-ui/packages/client/connection/package.json create mode 100644 apps/web-ui/packages/client/connection/src/api-path.ts create mode 100644 apps/web-ui/packages/client/connection/src/api-request-trust.ts create mode 100644 apps/web-ui/packages/client/connection/src/browser-auth.ts create mode 100644 apps/web-ui/packages/client/connection/src/client/api.ts create mode 100644 apps/web-ui/packages/client/connection/src/client/connection.ts create mode 100644 apps/web-ui/packages/client/connection/src/client/index.ts create mode 100644 apps/web-ui/packages/client/connection/src/client/random-uuid.ts create mode 100644 apps/web-ui/packages/client/connection/src/client/rpc.ts create mode 100644 apps/web-ui/packages/client/connection/src/http-bridge.ts create mode 100644 apps/web-ui/packages/client/connection/src/index.ts create mode 100644 apps/web-ui/packages/client/connection/src/loopback-hostname.ts create mode 100644 apps/web-ui/packages/client/connection/src/operator-peer.ts create mode 100644 apps/web-ui/packages/client/connection/src/recovery-config.ts create mode 100644 apps/web-ui/packages/client/connection/src/rpc-host.ts create mode 100644 apps/web-ui/packages/client/connection/src/rpc-schema.ts create mode 100644 apps/web-ui/packages/client/connection/src/rpc.ts create mode 100644 apps/web-ui/packages/client/connection/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/file-upload/generated/remote.d.ts create mode 100644 apps/web-ui/packages/client/file-upload/generated/remote.js create mode 100644 apps/web-ui/packages/client/file-upload/package.json create mode 100644 apps/web-ui/packages/client/file-upload/src/client/contract.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/client/index.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/client/runtime.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/http-route.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/index.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/protocol.ts create mode 100644 apps/web-ui/packages/client/file-upload/src/types.ts create mode 100644 apps/web-ui/packages/client/file-upload/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/locale/package.json create mode 100644 apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css create mode 100644 apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx create mode 100644 apps/web-ui/packages/client/locale/src/client/bootstrap.ts create mode 100644 apps/web-ui/packages/client/locale/src/client/index.ts create mode 100644 apps/web-ui/packages/client/locale/src/client/settings-store.ts create mode 100644 apps/web-ui/packages/client/locale/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/locale/src/index.ts create mode 100644 apps/web-ui/packages/client/locale/src/locale-settings.ts create mode 100644 apps/web-ui/packages/client/locale/src/locales/en.ts create mode 100644 apps/web-ui/packages/client/locale/src/locales/index.ts create mode 100644 apps/web-ui/packages/client/locale/src/locales/settings.ts create mode 100644 apps/web-ui/packages/client/locale/src/locales/zh.ts create mode 100644 apps/web-ui/packages/client/locale/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/modules/package.json create mode 100644 apps/web-ui/packages/client/modules/src/client/entries.ts create mode 100644 apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts create mode 100644 apps/web-ui/packages/client/modules/src/client/index.ts create mode 100644 apps/web-ui/packages/client/modules/src/client/manifest.ts create mode 100644 apps/web-ui/packages/client/modules/src/client/system.ts create mode 100644 apps/web-ui/packages/client/modules/src/index.ts create mode 100644 apps/web-ui/packages/client/modules/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/product-analytics/generated/remote.d.ts create mode 100644 apps/web-ui/packages/client/product-analytics/generated/remote.js create mode 100644 apps/web-ui/packages/client/product-analytics/package.json create mode 100644 apps/web-ui/packages/client/product-analytics/src/client/index.ts create mode 100644 apps/web-ui/packages/client/product-analytics/src/events.ts create mode 100644 apps/web-ui/packages/client/product-analytics/src/index.ts create mode 100644 apps/web-ui/packages/client/product-analytics/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/resources/package.json create mode 100644 apps/web-ui/packages/client/resources/src/client/contract.ts create mode 100644 apps/web-ui/packages/client/resources/src/client/index.ts create mode 100644 apps/web-ui/packages/client/resources/src/client/resources.ts create mode 100644 apps/web-ui/packages/client/resources/src/index.ts create mode 100644 apps/web-ui/packages/client/resources/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/shortcuts/package.json create mode 100644 apps/web-ui/packages/client/shortcuts/src/binding.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/dom.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/index.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/native.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/registry.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/storage.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/client/types.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/config.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/configuration.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/index.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/native.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/persistence.ts create mode 100644 apps/web-ui/packages/client/shortcuts/src/protocol.ts create mode 100644 apps/web-ui/packages/client/shortcuts/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/store/package.json create mode 100644 apps/web-ui/packages/client/store/src/contract.ts create mode 100644 apps/web-ui/packages/client/store/src/index.ts create mode 100644 apps/web-ui/packages/client/store/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/tsdown.client.ts create mode 100644 apps/web-ui/packages/client/ui-approval/package.json create mode 100644 apps/web-ui/packages/client/ui-approval/src/client/ApprovalPanel.module.css create mode 100644 apps/web-ui/packages/client/ui-approval/src/client/ApprovalPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-approval/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-approval/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-approval/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-approval/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-approval/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-approval/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/package.json create mode 100644 apps/web-ui/packages/client/ui-attachment/src/AttachmentRail.module.css create mode 100644 apps/web-ui/packages/client/ui-attachment/src/AttachmentRail.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/DropOverlay.module.css create mode 100644 apps/web-ui/packages/client/ui-attachment/src/DropOverlay.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/FileCard.module.css create mode 100644 apps/web-ui/packages/client/ui-attachment/src/FileCard.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/MessageImage.module.css create mode 100644 apps/web-ui/packages/client/ui-attachment/src/MessageImage.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/ComposerAttachments.module.css create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/ComposerAttachments.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/MessageImages.tsx create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/drop-events.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/src/client/labels.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-attachment/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-brand-official/package.json create mode 100644 apps/web-ui/packages/client/ui-brand-official/src/client/Brand.tsx create mode 100644 apps/web-ui/packages/client/ui-brand-official/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-brand-official/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-brand-official/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-chat/package.json create mode 100644 apps/web-ui/packages/client/ui-chat/src/chat-settings.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/apply.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/CommandNodeView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/CompactionCommandCard.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/CompactionItem.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ContextBody.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ContextBody.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/MessageItem.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/MessageItem.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/QuotaNoticeHost.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/RunningStatus.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/StatsPills.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/StatsPills.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/accessibility.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/message-chrome.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/register-node-renderers.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/render-entry.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/running-whale@2x.png create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/searchable-hidden.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/stat-dialog.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/stat-dialog.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/step-process.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/token-format.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/turn-assistant.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/turn-rail-items.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/turn-trigger.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-calendar-day.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-chat-navigation.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-chat-reading.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-disclosure.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-process-scroll.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-scroll-follow.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/chat/use-turn-data.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/assistant-content.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/chat-nodes.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/chat-visibility.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/process-groups.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/snapshot.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/store.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/turn-metrics.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/contract/turn-process.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/README.md create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/command.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/common.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/message.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/register.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/retry.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/tool.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/locale.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/markdown-labels.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/model/conversation-context.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/model/steering-history.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/model/tool-call-tree.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/performance-usage.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/presentation-policy.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/settings/PerformanceUsageRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/settings/PreferenceRow.module.css create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/settings/PreferenceRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/stores.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/client/transcript-view.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-chat/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-chat/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/package.json create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/ImportIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/ImportPage.module.css create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/ImportPage.tsx create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/NotificationsRow.module.css create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/NotificationsRow.tsx create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/api.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/notifications.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-codexhost/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-commands/package.json create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/PopupSelectView.module.css create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/PopupSelectView.tsx create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/contract.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/directory.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/option-groups.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/popup.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/presentation.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/resolution.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-commands/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-commands/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/package.json create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/apply.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/context-occupancy.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/composer-blocks.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/composer-submission.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/context-producer.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/conversation.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/draft-editor.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/groups.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/input.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/records.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/request-inspection.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/snapshot.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/system-prompt.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/contract/views.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/assembler.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/assembly.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/definition-registry.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/event-registry.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/group-registry.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/group-store.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/historical-images.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/location-index.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/conversation/view-registry.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/draft.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/image-labels.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/blocks.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/decorations.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/ComposerContentEditable.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/DecoratorPortals.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/DraftEditor.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/chip-node.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/keymap.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/projection.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/reference-activation.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/runtime.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/span-map.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/text-ref.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/editor/view-binding.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/facade.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/hub.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/machine.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/submission-analytics.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/input/submission-policy.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/queue/QueueDock.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/queue/QueueDock.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/control-row-layout.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/skeleton/safari.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/stop-sequence.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/stop-shortcut.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/stores.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/client/view-selection.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/src/submission-settings.ts create mode 100644 apps/web-ui/packages/client/ui-conversation/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/package.json create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/client/flow.ts create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-directory-picker-browse/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/package.json create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/DockSurface.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/FloatLayer.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/PaneTree.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/TabLayout.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/TabMenu.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/TabPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/TabTitle.tsx create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/dockkit.module.css create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/measure.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/pointer.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/components/render.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/contract/adapter.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/contract/types.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/constraints.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/controller.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/geometry.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/initial.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/operations.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/planner.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/sequence.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/engine/tree.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-dockkit/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/package.json create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/MenuView.module.css create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/MenuView.tsx create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/contract.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/controller.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/client/slots.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/core/contract.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/core/detect.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/core/menu.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/src/types.ts create mode 100644 apps/web-ui/packages/client/ui-input-trigger/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-layout/package.json create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/AppFrame.module.css create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/AppFrame.tsx create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/DocumentTitle.tsx create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/columns.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/shortcut-locales.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/stores.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/client/theme-presenter.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-layout/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-layout/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/package.json create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.module.css create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.tsx create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/catalog.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/directory.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/provider-order.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/slots.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-model-selection/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/package.json create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionRow.module.css create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionRow.tsx create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/catalog.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/presentation.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/settings-store.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-permission-presets/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-plan/package.json create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/PlanCard.tsx create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/PlanModeControl.module.css create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/PlanModeControl.tsx create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/PlanPreview.module.css create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/PlanPreview.tsx create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/failure-line.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/plan-definition.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/plan-resource.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/plan.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/review-preview.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/client/review-store.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-plan/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-plan/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/generated/remote.d.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/generated/remote.js create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/package.json create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/PluginsPanelIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/config-ledger.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/manager-store.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/navigation-store.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/presentation.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/client/slot-contract.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-plugin-manager/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/package.json create mode 100644 apps/web-ui/packages/client/ui-primitives/src/BrandWordmark.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Button.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Button.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Checkbox.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Checkbox.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/CodeCard.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/CodeFileIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/CodeToolbar.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ConfigField.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ConfigField.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ConnectionIndicator.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ConnectionIndicator.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/DiffBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/DiffBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/DisclosureRow.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/DisclosureRow.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/FileTypeIcon.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/FileTypeIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/FishLogo.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/FoldToggle.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/HoverCard.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/HoverCard.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ImageLightbox.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ImageLightbox.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ImagePreview.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ImagePreview.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/InlineEditor.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/InlineEditor.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Input.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Input.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/JsonTree.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/JsonTree.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/LinkIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Menu.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Menu.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/MenuGroup.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/MenuGroup.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/MenuSurface.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/MenuSurface.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Modal.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Modal.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/PathLabel.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/PathLabel.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/PermissionIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Pill.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Pill.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ReadBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ReadBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ReferenceIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/RiskConfirmation.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/RiskConfirmation.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SearchBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SearchBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SegmentedControl.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SegmentedControl.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SegmentedTabs.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SegmentedTabs.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ShortcutKeys.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ShortcutKeys.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/SiteGlyph.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/StateDot.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/StateDot.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Switch.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Switch.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Tag.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Tag.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/TerminalBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/TerminalBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/TextShimmer.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/TextShimmer.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Toast.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Toast.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Tooltip.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/Tooltip.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/WebBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/WebBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/ansi.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/clipboard.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json create mode 100644 apps/web-ui/packages/client/ui-primitives/src/code-file-icon-artwork.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/code-file-types.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/code-highlighting.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/darwin-desktop.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/file-size.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/focus.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/guide-artwork.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/head-tail-cap.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/icons/index.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/icons/props.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/icons/shared-artwork.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/input-modality.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/keyboard-composition.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/CodeBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/CodeBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/JsonBlock.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/JsonBlock.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/MarkdownText.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/MarkdownText.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/cjkFriendlyStrong.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/file-link.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/highlight.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/incremental.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/katex.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/local-image-syntax.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/mathCompatibility.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/parse.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/plain-text.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/render.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/overlay-top-margin.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/plugin-artwork.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/pointer-grace.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/rank-by-name.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/relative-time.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/settings-form/fields.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/settings-form/fields.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/settings-form/form-model.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/use-copy-feedback.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/useAnchoredMaxHeight.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/useAnchoredPosition.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/useModalLayer.ts create mode 100644 apps/web-ui/packages/client/ui-primitives/src/user-text.module.css create mode 100644 apps/web-ui/packages/client/ui-primitives/src/user-text.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/package.json create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/app.tsx create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/bind.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/bindings.tsx create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/errors.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/registry.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/scoped-slots.tsx create mode 100644 apps/web-ui/packages/client/ui-renderer/src/client/use-sync-external-store.d.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-renderer/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-session/package.json create mode 100644 apps/web-ui/packages/client/ui-session/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-session/src/client/session-provider.tsx create mode 100644 apps/web-ui/packages/client/ui-session/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-session/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/package.json create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/CurrentVersionRow.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/CurrentVersionRow.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/DeveloperToolsRow.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/DeveloperToolsRow.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/GeneralSection.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/GeneralSection.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/SettingsDocumentAction.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/SettingsDocumentAction.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/chrome.module.css create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/chrome.tsx create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/desktop-update-source.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/settings-document-store.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/shell-contract.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/client/shell-store.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/src/types.ts create mode 100644 apps/web-ui/packages/client/ui-settings-general/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-settings/package.json create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/config-form-types.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/config-form.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/developer-tools.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/schema.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/client/settings-mirror.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/developer-tools-settings.ts create mode 100644 apps/web-ui/packages/client/ui-settings/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-settings/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/package.json create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/Editor.tsx create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/Icons.tsx create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/Reference.module.css create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/Reference.tsx create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/feedback.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/fixed.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/client/store.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-shortcuts/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/package.json create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/params.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/seed.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/focus.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/labels.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/persistence.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/service.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/session-view.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/session-views.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shell/close-focus.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/shortcuts.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/stores.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-domain.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-info.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-inventory.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tab-registry.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar-right/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/package.json create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.module.css create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-sidebar/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-slots/package.json create mode 100644 apps/web-ui/packages/client/ui-slots/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-slots/src/renderer.ts create mode 100644 apps/web-ui/packages/client/ui-slots/src/store.ts create mode 100644 apps/web-ui/packages/client/ui-slots/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-subagent/package.json create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.module.css create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css create mode 100644 apps/web-ui/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx create mode 100644 apps/web-ui/packages/client/ui-subagent/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-subagent/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-subagent/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-theme/package.json create mode 100644 apps/web-ui/packages/client/ui-theme/src/boot-theme.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.module.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/AppearanceRow.tsx create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.module.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/FontSizeRow.tsx create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/settings-store.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/client/styles.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/Montserrat-OFL.txt create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/base.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/brand-font.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/corner-shape.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/design-platform.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/focus.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/gradient-shadow-text.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/montserrat-light.woff2 create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/montserrat-medium.woff2 create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/montserrat-regular.woff2 create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/onboarding.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/scrollbar.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/styles/shiki.css create mode 100644 apps/web-ui/packages/client/ui-theme/src/theme-settings.ts create mode 100644 apps/web-ui/packages/client/ui-theme/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-tool/package.json create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/apply.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/locale.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/QuestionToolRow.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/auto-review-denial.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/control-details-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/details-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/diff-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/image-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/primitive-labels.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/read-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/search-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/todo-history.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/models/web-card-model.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx create mode 100644 apps/web-ui/packages/client/ui-tool/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-tool/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-tool/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/package.json create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.module.css create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/QuestionComposer.tsx create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/QuestionReplyView.module.css create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/QuestionReplyView.tsx create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/draft-store.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/client/question-reply.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-user-questions/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/package.json create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/WorkspacePicker.module.css create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/WorkspacePicker.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/contract/slots.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/index.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/locales.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/navigation.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/pin-order.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/AnimatedRows.module.css create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/AnimatedRows.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/session-actions/derived.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/shortcuts.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/stores.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/client/tree.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/src/index.ts create mode 100644 apps/web-ui/packages/client/ui-workspace/tsdown.config.ts create mode 100644 apps/web-ui/packages/client/web/package.json create mode 100644 apps/web-ui/packages/client/web/src/apply-injections.ts create mode 100644 apps/web-ui/packages/client/web/src/base.css create mode 100644 apps/web-ui/packages/client/web/src/boot-client.ts create mode 100644 apps/web-ui/packages/client/web/src/boot-page.module.css create mode 100644 apps/web-ui/packages/client/web/src/boot-page.ts create mode 100644 apps/web-ui/packages/client/web/src/boot.ts create mode 100644 apps/web-ui/packages/client/web/src/css-modules.d.ts create mode 100644 apps/web-ui/packages/client/web/src/index.ts create mode 100644 apps/web-ui/packages/client/web/src/loader-status.ts create mode 100644 apps/web-ui/packages/client/web/src/mount.ts create mode 100644 apps/web-ui/packages/client/web/src/platform.ts create mode 100644 apps/web-ui/packages/client/web/src/seed.ts create mode 100644 apps/web-ui/packages/client/web/src/window-drag/recall.ts create mode 100644 apps/web-ui/packages/client/web/src/window-drag/regions.ts create mode 100644 apps/web-ui/packages/client/web/tsdown.config.ts create mode 100644 apps/web-ui/packages/context/file-reference/package.json create mode 100644 apps/web-ui/packages/context/file-reference/src/grammar.ts create mode 100644 apps/web-ui/packages/context/file-reference/src/index.ts create mode 100644 apps/web-ui/packages/context/file-reference/src/types.ts create mode 100644 apps/web-ui/packages/context/session-reference/generated/remote.d.ts create mode 100644 apps/web-ui/packages/context/session-reference/generated/remote.js create mode 100644 apps/web-ui/packages/context/session-reference/package.json create mode 100644 apps/web-ui/packages/context/session-reference/src/config.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/index.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/projection.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/serialization.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/spill.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/types.ts create mode 100644 apps/web-ui/packages/context/session-reference/src/uri.ts create mode 100644 apps/web-ui/packages/core/session/package.json create mode 100644 apps/web-ui/packages/core/session/src/fork.ts create mode 100644 apps/web-ui/packages/core/session/src/index.ts create mode 100644 apps/web-ui/packages/core/session/src/known-event-types.ts create mode 100644 apps/web-ui/packages/core/session/src/preparation.ts create mode 100644 apps/web-ui/packages/core/session/src/repair.ts create mode 100644 apps/web-ui/packages/core/session/src/request-header.ts create mode 100644 apps/web-ui/packages/core/session/src/seq-ranges.ts create mode 100644 apps/web-ui/packages/core/session/src/surface.ts create mode 100644 apps/web-ui/packages/core/session/src/tool-history.ts create mode 100644 apps/web-ui/packages/core/session/src/types.ts create mode 100644 apps/web-ui/packages/core/session/tsdown.config.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/generated/remote.d.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/generated/remote.js create mode 100644 apps/web-ui/packages/document/office-to-pdf/package.json create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/errors.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/identity.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/index.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/output.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/queue.ts create mode 100644 apps/web-ui/packages/document/office-to-pdf/src/types.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/generated/remote.d.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/generated/remote.js create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/package.json create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/guard.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/index.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/inspect-registry.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/lifecycle.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/registry.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/sandbox.ts create mode 100644 apps/web-ui/packages/extensions/cordis-host-runner/src/types.ts create mode 100644 apps/web-ui/packages/feedback/command-feedback/generated/remote.d.ts create mode 100644 apps/web-ui/packages/feedback/command-feedback/generated/remote.js create mode 100644 apps/web-ui/packages/feedback/command-feedback/package.json create mode 100644 apps/web-ui/packages/feedback/command-feedback/src/index.ts create mode 100644 apps/web-ui/packages/feedback/command-feedback/src/types.ts create mode 100644 apps/web-ui/packages/feedback/message-feedback/generated/remote.d.ts create mode 100644 apps/web-ui/packages/feedback/message-feedback/generated/remote.js create mode 100644 apps/web-ui/packages/feedback/message-feedback/package.json create mode 100644 apps/web-ui/packages/feedback/message-feedback/src/index.ts create mode 100644 apps/web-ui/packages/feedback/message-feedback/src/types.ts create mode 100644 apps/web-ui/packages/goal/goal/generated/remote.d.ts create mode 100644 apps/web-ui/packages/goal/goal/generated/remote.js create mode 100644 apps/web-ui/packages/goal/goal/package.json create mode 100644 apps/web-ui/packages/goal/goal/src/client.ts create mode 100644 apps/web-ui/packages/goal/goal/src/domain.ts create mode 100644 apps/web-ui/packages/goal/goal/src/fold.ts create mode 100644 apps/web-ui/packages/goal/goal/src/index.ts create mode 100644 apps/web-ui/packages/goal/goal/src/runtime.ts create mode 100644 apps/web-ui/packages/goal/goal/src/types.ts create mode 100644 apps/web-ui/packages/goal/goal/tsdown.config.ts create mode 100644 apps/web-ui/packages/host/plugin-inventory/generated/remote.d.ts create mode 100644 apps/web-ui/packages/host/plugin-inventory/generated/remote.js create mode 100644 apps/web-ui/packages/host/plugin-inventory/package.json create mode 100644 apps/web-ui/packages/host/plugin-inventory/src/index.ts create mode 100644 apps/web-ui/packages/host/plugin-inventory/src/types.ts create mode 100644 apps/web-ui/packages/interaction/commands/generated/remote.d.ts create mode 100644 apps/web-ui/packages/interaction/commands/generated/remote.js create mode 100644 apps/web-ui/packages/interaction/commands/package.json create mode 100644 apps/web-ui/packages/interaction/commands/src/brand.ts create mode 100644 apps/web-ui/packages/interaction/commands/src/index.ts create mode 100644 apps/web-ui/packages/interaction/commands/src/types.ts create mode 100644 apps/web-ui/packages/interaction/permission-presets/generated/remote.d.ts create mode 100644 apps/web-ui/packages/interaction/permission-presets/generated/remote.js create mode 100644 apps/web-ui/packages/interaction/permission-presets/package.json create mode 100644 apps/web-ui/packages/interaction/permission-presets/src/client.ts create mode 100644 apps/web-ui/packages/interaction/permission-presets/src/index.ts create mode 100644 apps/web-ui/packages/interaction/permission-presets/src/types.ts create mode 100644 apps/web-ui/packages/interaction/user-questions/generated/remote.d.ts create mode 100644 apps/web-ui/packages/interaction/user-questions/generated/remote.js create mode 100644 apps/web-ui/packages/interaction/user-questions/package.json create mode 100644 apps/web-ui/packages/interaction/user-questions/src/index.ts create mode 100644 apps/web-ui/packages/interaction/user-questions/src/projection.ts create mode 100644 apps/web-ui/packages/interaction/user-questions/src/timed-wait.ts create mode 100644 apps/web-ui/packages/interaction/user-questions/src/types.ts create mode 100644 apps/web-ui/packages/llm/llm/generated/remote.d.ts create mode 100644 apps/web-ui/packages/llm/llm/generated/remote.js create mode 100644 apps/web-ui/packages/llm/llm/package.json create mode 100644 apps/web-ui/packages/llm/llm/src/adapter-failure.ts create mode 100644 apps/web-ui/packages/llm/llm/src/api-key.ts create mode 100644 apps/web-ui/packages/llm/llm/src/assembler.ts create mode 100644 apps/web-ui/packages/llm/llm/src/assistant-stream.ts create mode 100644 apps/web-ui/packages/llm/llm/src/attribution.ts create mode 100644 apps/web-ui/packages/llm/llm/src/brand.ts create mode 100644 apps/web-ui/packages/llm/llm/src/call-config.ts create mode 100644 apps/web-ui/packages/llm/llm/src/content.ts create mode 100644 apps/web-ui/packages/llm/llm/src/error.ts create mode 100644 apps/web-ui/packages/llm/llm/src/index.ts create mode 100644 apps/web-ui/packages/llm/llm/src/message.ts create mode 100644 apps/web-ui/packages/llm/llm/src/retry-policy.ts create mode 100644 apps/web-ui/packages/llm/llm/src/types.ts create mode 100644 apps/web-ui/packages/llm/token-meter/package.json create mode 100644 apps/web-ui/packages/llm/token-meter/src/breakdown-projection.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/client.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/estimate.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/index.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/projection.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/route-pricing.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/surface-fold.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/surface-projection.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/turn-usage.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/types.ts create mode 100644 apps/web-ui/packages/llm/token-meter/src/usage-projection.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/generated/remote.d.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/generated/remote.js create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/package.json create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/composition-inventory.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/definition.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/display.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/index.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/mount.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/preset.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/session.ts create mode 100644 apps/web-ui/packages/preset/agent-preset-registry/src/types.ts create mode 100644 apps/web-ui/packages/schedule/schedule/generated/remote.d.ts create mode 100644 apps/web-ui/packages/schedule/schedule/generated/remote.js create mode 100644 apps/web-ui/packages/schedule/schedule/package.json create mode 100644 apps/web-ui/packages/schedule/schedule/src/client.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/delivery-history.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/domain.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/index.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/runtime.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/storage.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/types.ts create mode 100644 apps/web-ui/packages/schedule/schedule/src/update.ts create mode 100644 apps/web-ui/packages/schedule/schedule/tsdown.config.ts create mode 100644 apps/web-ui/packages/spill/spill-policy/package.json create mode 100644 apps/web-ui/packages/spill/spill-policy/src/index.ts create mode 100644 apps/web-ui/packages/spill/spill-policy/src/notice.ts create mode 100644 apps/web-ui/packages/spill/spill-policy/src/retention.ts create mode 100644 apps/web-ui/packages/spill/spill-policy/src/types.ts create mode 100644 apps/web-ui/packages/subagent/subagent/generated/remote.d.ts create mode 100644 apps/web-ui/packages/subagent/subagent/generated/remote.js create mode 100644 apps/web-ui/packages/subagent/subagent/package.json create mode 100644 apps/web-ui/packages/subagent/subagent/src/archive-admission.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/assistant-output.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/catalog.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/child-agent.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/client.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/continuation-activation.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/continuation-messages.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/continuation.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/control-types.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/control.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/depth.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/descriptor.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/error.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/inbox.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/index.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/internal.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/lifecycle.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/list-children.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/out-of-process.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/projection-types.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/projection.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/run-settlement.ts create mode 100644 apps/web-ui/packages/subagent/subagent/src/types.ts create mode 100644 apps/web-ui/packages/typert/protocol/package.json create mode 100644 apps/web-ui/packages/typert/protocol/src/index.ts create mode 100644 apps/web-ui/packages/typert/protocol/src/json-value.ts create mode 100644 apps/web-ui/packages/typert/protocol/src/owned-value.ts create mode 100644 apps/web-ui/packages/typert/protocol/src/remote-error.ts create mode 100644 apps/web-ui/packages/typert/protocol/src/types.ts create mode 100644 apps/web-ui/packages/typert/registry/package.json create mode 100644 apps/web-ui/packages/typert/registry/src/client/index.ts create mode 100644 apps/web-ui/packages/typert/registry/src/index.ts create mode 100644 apps/web-ui/packages/typert/registry/src/service.ts create mode 100644 apps/web-ui/packages/typert/registry/src/types.ts create mode 100644 apps/web-ui/packages/typert/registry/tsdown.config.ts create mode 100644 apps/web-ui/packages/util/brand/package.json create mode 100644 apps/web-ui/packages/util/brand/src/index.ts create mode 100644 apps/web-ui/packages/util/code-language/package.json create mode 100644 apps/web-ui/packages/util/code-language/src/index.ts create mode 100644 apps/web-ui/packages/util/crypto/package.json create mode 100644 apps/web-ui/packages/util/crypto/src/index.ts create mode 100644 apps/web-ui/packages/util/deque/package.json create mode 100644 apps/web-ui/packages/util/deque/src/index.ts create mode 100644 apps/web-ui/packages/util/output-retention/package.json create mode 100644 apps/web-ui/packages/util/output-retention/src/index.ts create mode 100644 apps/web-ui/packages/util/values/package.json create mode 100644 apps/web-ui/packages/util/values/src/index.ts create mode 100644 apps/web-ui/packages/util/values/src/partial-json.ts create mode 100644 apps/web-ui/packages/util/workspace-path/package.json create mode 100644 apps/web-ui/packages/util/workspace-path/src/file-address.ts create mode 100644 apps/web-ui/packages/util/workspace-path/src/index.ts create mode 100644 apps/web-ui/scripts/build.ts create mode 100644 apps/web-ui/scripts/bundle-input-isolation.ts create mode 100644 apps/web-ui/scripts/client-build-environment.ts create mode 100644 apps/web-ui/scripts/web-product-bundle-isolation.ts create mode 100644 apps/web-ui/tsconfig.base.json create mode 100644 apps/web-ui/tsconfig.json create mode 100644 apps/web-ui/vendor/cordis/LICENSE create mode 100644 apps/web-ui/vendor/cordis/package.json create mode 100644 apps/web-ui/vendor/cordis/src/context.ts create mode 100644 apps/web-ui/vendor/cordis/src/events.ts create mode 100644 apps/web-ui/vendor/cordis/src/fiber.ts create mode 100644 apps/web-ui/vendor/cordis/src/index.ts create mode 100644 apps/web-ui/vendor/cordis/src/logger.ts create mode 100644 apps/web-ui/vendor/cordis/src/reflect.ts create mode 100644 apps/web-ui/vendor/cordis/src/registry.ts create mode 100644 apps/web-ui/vendor/cordis/src/service.ts create mode 100644 apps/web-ui/vendor/cordis/src/utils.ts create mode 100644 apps/web-ui/vendor/cosmokit/LICENSE create mode 100644 apps/web-ui/vendor/cosmokit/package.json create mode 100644 apps/web-ui/vendor/cosmokit/src/array.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/index.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/misc.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/string.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/time.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/types.ts create mode 100644 apps/web-ui/vendor/cosmokit/src/volatile.ts create mode 100644 apps/web-ui/vendor/loader/LICENSE create mode 100644 apps/web-ui/vendor/loader/package.json create mode 100644 apps/web-ui/vendor/loader/src/config/diff.ts create mode 100644 apps/web-ui/vendor/loader/src/config/entry.ts create mode 100644 apps/web-ui/vendor/loader/src/config/group.ts create mode 100644 apps/web-ui/vendor/loader/src/config/isolate.ts create mode 100644 apps/web-ui/vendor/loader/src/config/tree.ts create mode 100644 apps/web-ui/vendor/loader/src/config/utils.ts create mode 100644 apps/web-ui/vendor/loader/src/index.ts create mode 100644 apps/web-ui/vendor/loader/src/internal.ts create mode 100644 apps/web-ui/vendor/loader/tsdown.config.ts create mode 100644 apps/web-ui/vendor/schemastery/LICENSE create mode 100644 apps/web-ui/vendor/schemastery/package.json create mode 100644 apps/web-ui/vendor/schemastery/src/index.ts create mode 100644 apps/web-ui/vendor/schemastery/tsdown.config.ts create mode 100644 docs/product/standalone-web.md create mode 100644 packages/adapters/codex/LICENSE.upstream create mode 100644 packages/adapters/codex/README.md create mode 100644 packages/adapters/codex/assets/icon.svg create mode 100644 packages/adapters/codex/manifest.json create mode 100644 packages/adapters/codex/package.json create mode 100644 packages/adapters/codex/src/items.ts create mode 100644 packages/adapters/codex/src/plugin.ts create mode 100644 packages/adapters/codex/src/rpc.ts create mode 100644 packages/adapters/codex/src/session.ts create mode 100644 packages/adapters/codex/test/items.test.ts create mode 100644 packages/adapters/codex/tsconfig.json create mode 100644 packages/web-server/LICENSE.upstream create mode 100644 packages/web-server/README.md create mode 100644 packages/web-server/fixtures/settings-namespaces.json create mode 100644 packages/web-server/package.json create mode 100644 packages/web-server/scripts/pack.ts create mode 100644 packages/web-server/scripts/preview.ts create mode 100644 packages/web-server/src/auth.ts create mode 100644 packages/web-server/src/check-plugins.ts create mode 100644 packages/web-server/src/harnesses.ts create mode 100644 packages/web-server/src/main.ts create mode 100644 packages/web-server/src/misc.ts create mode 100644 packages/web-server/src/packaged-adapters.ts create mode 100644 packages/web-server/src/plugins.ts create mode 100644 packages/web-server/src/preview-launch.ts create mode 100644 packages/web-server/src/projector.ts create mode 100644 packages/web-server/src/push.ts create mode 100644 packages/web-server/src/qrcode-terminal.d.ts create mode 100644 packages/web-server/src/session-log.ts create mode 100644 packages/web-server/src/sessions.ts create mode 100644 packages/web-server/src/settings.ts create mode 100644 packages/web-server/src/store.ts create mode 100644 packages/web-server/src/transport.ts create mode 100644 packages/web-server/src/web-assets.ts create mode 100644 packages/web-server/src/web-push.d.ts create mode 100644 packages/web-server/src/workspaces.ts create mode 100644 packages/web-server/test/browser/web.test.ts create mode 100644 packages/web-server/test/distribution/pack.test.ts create mode 100644 packages/web-server/test/distribution/source-plugins.test.ts create mode 100644 packages/web-server/test/fake-harness/fake/manifest.json create mode 100644 packages/web-server/test/fake-harness/fake/plugin.ts create mode 100644 packages/web-server/test/fixtures/claude-question-spanning-tool.jsonl create mode 100644 packages/web-server/test/packaged-adapters.test.ts create mode 100644 packages/web-server/test/preview-launch.test.ts create mode 100644 packages/web-server/test/projector.test.ts create mode 100644 packages/web-server/test/sessions.integration.test.ts create mode 100644 packages/web-server/test/support/defined.ts create mode 100644 packages/web-server/test/support/server.ts create mode 100644 packages/web-server/tsconfig.json diff --git a/.gitignore b/.gitignore index 495c5976f..863f71789 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ # Dependencies and tool caches node_modules/ +.work/ +.dsh-build/ +/apps/web-ui/**/lib/ .npm/ .cache/ .wix/ diff --git a/.prettierignore b/.prettierignore index ebadfaa33..101680903 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,8 @@ package-lock.json Cargo.lock # Generated models.dev price snapshot. packages/host-runtime/src/model-prices.generated.ts +# Pinned upstream browser protocol codecs. +apps/web-ui/**/generated/ +# Imported browser build outputs and local diagnostics. +apps/web-ui/**/lib/ +.work/ diff --git a/README.md b/README.md index 3c3c68d05..092d922f4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 Claude Code, Pi, Grok Build, and Oh My Pi sessions running in Codex Desktop, with Diff review, Fork, Worktree, and Agent switching +## Standalone Web development + +This checkout also contains an independent Web/PWA application using the same Harness Adapter sources. Run `npm ci`, `npm run build:web`, then `npm run start:web` for the isolated Claude Code preview. This does not launch or restart Codex Desktop. See [the Web guide](docs/product/standalone-web.md) for plugin distribution, storage isolation and current limitations. + ## Quick Start **Option 1: npm** (macOS / Windows / Linux) diff --git a/apps/web-ui/LICENSE b/apps/web-ui/LICENSE new file mode 100644 index 000000000..c1f7a78e8 --- /dev/null +++ b/apps/web-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DeepSeek + +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/apps/web-ui/README.md b/apps/web-ui/README.md new file mode 100644 index 000000000..2dc0954d6 --- /dev/null +++ b/apps/web-ui/README.md @@ -0,0 +1,11 @@ +# CodexHost Web UI + +Private browser workspaces imported from the MIT-licensed `deepseek-harness` 0.2.1-alpha.1 Web UI and the local `codexhost-web` fork. The import keeps the selected client plugins, their browser runtime and protocol dependencies, and local CodexHost/mobile changes. It does not include the upstream CLI, release machinery, independent agent execution, or a second Git repository. + +The source roster is in `import-manifest.json`. `packages/` and `vendor/` are npm workspaces of the parent CodexHost repository; install dependencies once at that repository root. `npm run build:web:ui` compiles plugin factories with the imported CSS/module-loader preset and builds `apps/web` with Vite. `lib/` and `dist/` are generated and ignored. All Harness execution belongs to the parent repository's Adapters and Web Server, not these browser packages. + +The `generated/remote.js` and matching declarations in selected packages are pinned DSH protocol codecs from the imported fork's generated Host descriptors. They are protocol source snapshots, not copies of Harness SDKs or bundled applications. Keep them aligned with the Web Server's DSH protocol compatibility layer; do not generate them from the Desktop Host. The full upstream frontend type-check and test programs are not imported yet; the current integration uses source compilation and the Web Server's browser regression tests. + +The upstream MIT license is retained in `LICENSE`, framework package licenses remain under `vendor/`, and upstream dependency notices are retained in `THIRD_PARTY_NOTICES.md`. Local import adaptations include private npm manifests, source exports, the selected source build, and removal of the upstream standalone Web Worker preview entry. The root LGPL license does not replace these notices. + +See [the Web feature document](../../docs/product/standalone-web.md) for runtime isolation, commands and supported behavior. diff --git a/apps/web-ui/THIRD_PARTY_NOTICES.md b/apps/web-ui/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..3602ce87c --- /dev/null +++ b/apps/web-ui/THIRD_PARTY_NOTICES.md @@ -0,0 +1,297 @@ + + +# Third-Party Notices + +DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. + +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. + +The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). + +## Vendored source (`vendor/`) + +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the `@deepseek-ai` scope. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). + +| Package | Upstream name | Source | License | +| --- | --- | --- | --- | +| `@deepseek-ai/cosmokit` | `cosmokit` | [vendor/cosmokit](vendor/cosmokit/) | MIT | +| `@deepseek-ai/schemastery` | `schemastery` | [vendor/schemastery](vendor/schemastery/) | MIT | +| `@deepseek-ai/cordis` | `cordis` | [vendor/cordis](vendor/cordis/) | MIT | +| `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | [vendor/loader](vendor/loader/) | MIT | +| `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | [vendor/include](vendor/include/) | MIT | +| `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | [vendor/group](vendor/group/) | MIT | +| `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | [vendor/timer](vendor/timer/) | MIT | +| `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | [vendor/hmr](vendor/hmr/) | MIT | +| `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | [vendor/logger-console](vendor/logger-console/) | MIT | + +## Runtime npm dependencies + +External packages installed for runtime use or distributed inside the prebuilt browser artifacts. Browser inputs are resolved through the shipping tsdown and Vite configurations, independently of npm dependency sections. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. + +| Package | License | +| --- | --- | +| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 | +| [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript) | SEE LICENSE IN README.md | +| [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | +| [`@babel/code-frame`](https://github.com/babel/babel) | MIT | +| [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | +| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-ai/dsh-libreoffice-kit) | MPL-2.0 | +| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT | +| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | +| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | +| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC | +| [`@lexical/history`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/text`](https://github.com/facebook/lexical) | MIT | +| [`@lexical/utils`](https://github.com/facebook/lexical) | MIT | +| [`@modelcontextprotocol/client`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT | +| [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT | +| [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | +| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@playwright/mcp`](https://github.com/microsoft/playwright-mcp) | Apache-2.0 | +| [`@puppeteer/browsers`](https://github.com/puppeteer/puppeteer/tree/main/packages/browsers) | Apache-2.0 | +| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | +| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | +| [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT | +| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | +| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`acorn`](https://github.com/acornjs/acorn) | MIT | +| [`ajv`](https://github.com/ajv-validator/ajv) | MIT | +| [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`big.js`](https://github.com/MikeMcl/big.js) | MIT | +| [`buffer`](https://github.com/feross/buffer) | MIT | +| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | +| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | +| [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Apache-2.0 | +| [`clsx`](https://github.com/lukeed/clsx) | MIT | +| [`commander`](https://github.com/tj/commander.js) | MIT | +| [`compression`](https://github.com/expressjs/compression) | MIT | +| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | +| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | +| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | +| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | +| [`got`](https://github.com/sindresorhus/got) | MIT | +| [`immer`](https://github.com/immerjs/immer) | MIT | +| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | +| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | +| [`katex`](https://github.com/KaTeX/KaTeX) | MIT | +| [`koffi`](https://github.com/Koromix/koffi) | MIT | +| [`lexical`](https://github.com/facebook/lexical) | MIT | +| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | +| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | +| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | +| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | +| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT | +| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT | +| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | +| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | +| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`mime-types`](https://github.com/jshttp/mime-types) | MIT | +| [`negotiator`](https://github.com/jshttp/negotiator) | MIT | +| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | +| [`node-pty`](https://github.com/microsoft/node-pty) | MIT | +| [`open`](https://github.com/sindresorhus/open) | MIT | +| [`papaparse`](https://github.com/mholt/PapaParse) | MIT | +| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | +| [`picomatch`](https://github.com/micromatch/picomatch) | MIT | +| [`react`](https://github.com/facebook/react) | MIT | +| [`react-dom`](https://github.com/facebook/react) | MIT | +| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | +| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | +| [`semver`](https://github.com/npm/node-semver) | ISC | +| [`serve-static`](https://github.com/expressjs/serve-static) | MIT | +| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | +| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | +| [`shiki`](https://github.com/shikijs/shiki) | MIT | +| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | +| [`supports-color`](https://github.com/chalk/supports-color) | MIT | +| [`tsx`](https://github.com/privatenumber/tsx) | MIT | +| [`turndown`](https://github.com/mixmark-io/turndown) | MIT | +| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | +| [`undici`](https://github.com/nodejs/undici) | MIT | +| [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`ws`](https://github.com/websockets/ws) | MIT | +| [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 | +| [`yaml`](https://github.com/eemeli/yaml) | ISC | +| [`zod`](https://github.com/colinhacks/zod) | MIT | +| [`zustand`](https://github.com/pmndrs/zustand) | MIT | + +pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: + +- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch) +- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) +- `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) +- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) +- `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) +- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) + +## Chrome DevTools frontend + +The optional experimental Inspector distributes a locally compiled copy of [chrome-devtools-frontend 1.0.1638082](https://www.npmjs.com/package/chrome-devtools-frontend/v/1.0.1638082), from upstream revision [0e1186138ed519d9659c1874bf6375eca4483c72](https://chromium.googlesource.com/devtools/devtools-frontend/+/0e1186138ed519d9659c1874bf6375eca4483c72). The build includes the Chromium [BSD-3-Clause license](packages/experimental/inspector/assets/devtools/LICENSE) and the third-party license and notice files supplied by the npm source. The Chromium root license does not replace those dependencies' licenses. + + +## Official Claude Code platform payloads + +The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. + +The installed SDK 0.3.263 declares the following optional platform packages. Each carries the official Claude Code 2.1.263 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. + +| Optional platform package | Version | Declared license | +| --- | --- | --- | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | + + +## LibreOffice conversion kit + +`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-ai/dsh-libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. + + +## Development-only npm dependencies + +External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. + +| Package | License | +| --- | --- | +| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@electron/get`](https://github.com/electron/get) | MIT | +| [`@electron/notarize`](https://github.com/electron/notarize) | MIT | +| [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | +| [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@panzoom/panzoom`](https://github.com/timmywil/panzoom) | MIT | +| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | +| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/papaparse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/serve-static`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | +| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | +| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT | +| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | +| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT | +| [`cos-nodejs-sdk-v5`](https://github.com/tencentyun/cos-nodejs-sdk-v5) | ISC | +| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | +| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | +| [`dayjs`](https://github.com/iamkun/dayjs) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | +| [`electron`](https://github.com/electron/electron) | MIT | +| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | +| [`esbuild`](https://github.com/evanw/esbuild) | MIT | +| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | +| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`http-server`](https://github.com/http-party/http-server) | MIT | +| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | +| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | +| [`jsdom`](https://github.com/jsdom/jsdom) | MIT | +| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | +| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | +| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | +| [`oxlint`](https://github.com/oxc-project/oxc) | MIT | +| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | +| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | +| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | +| [`publint`](https://github.com/publint/publint) | MIT | +| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | +| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 | +| [`tsdown`](https://github.com/rolldown/tsdown) | MIT | +| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | +| [`vite`](https://github.com/vitejs/vite) | MIT | +| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | +| [`vitepress`](https://github.com/vuejs/vitepress) | MIT | +| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | +| [`vue`](https://github.com/vuejs/core) | MIT | + +`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. + +## Python SDK dependencies (`python/`) + +Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool. + +| Package | License | Role | +| --- | --- | --- | +| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | +| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | +| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | + +## Bundled Python distributions + +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. + +| Distribution | Locked version | Project license | +| --- | --- | --- | +| [`et-xmlfile`](https://foss.heptapod.net/openpyxl/et_xmlfile) | 2.0.0 | MIT | +| [`lxml`](https://github.com/lxml/lxml) | 6.1.3 | BSD-3-Clause | +| [`numpy`](https://github.com/numpy/numpy) | 2.3.5 | BSD-3-Clause | +| [`openpyxl`](https://foss.heptapod.net/openpyxl/openpyxl) | 3.1.5 | MIT | +| [`pandas`](https://github.com/pandas-dev/pandas) | 3.0.1 | BSD-3-Clause | +| [`pillow`](https://github.com/python-pillow/Pillow) | 12.3.0 | MIT-CMU | +| [`python-dateutil`](https://github.com/dateutil/dateutil) | 2.9.0.post0 | Apache-2.0 OR BSD-3-Clause | +| [`python-docx`](https://github.com/python-openxml/python-docx) | 1.2.0 | MIT | +| [`python-pptx`](https://github.com/scanny/python-pptx) | 1.0.2 | MIT | +| [`six`](https://github.com/benjaminp/six) | 1.17.0 | MIT | +| [`typing-extensions`](https://github.com/python/typing_extensions) | 4.16.0 | PSF-2.0 | +| [`tzdata`](https://github.com/python/tzdata) | 2025.2 | Apache-2.0 | +| [`xlsxwriter`](https://github.com/jmcnamara/XlsxWriter) | 3.2.9 | BSD-2-Clause | + +## First-party native packages + +`@deepseek-ai/node-addon-system` (and its platform packages) is built and released from this repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/apps/web-ui/apps/web/.npmignore b/apps/web-ui/apps/web/.npmignore new file mode 100644 index 000000000..46af539c1 --- /dev/null +++ b/apps/web-ui/apps/web/.npmignore @@ -0,0 +1 @@ +*.map \ No newline at end of file diff --git a/apps/web-ui/apps/web/index.html b/apps/web-ui/apps/web/index.html new file mode 100644 index 000000000..6b3458d2d --- /dev/null +++ b/apps/web-ui/apps/web/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + DSH Local Build + + +
+ + + diff --git a/apps/web-ui/apps/web/package.json b/apps/web-ui/apps/web/package.json new file mode 100644 index 000000000..2fd3e24a3 --- /dev/null +++ b/apps/web-ui/apps/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "@codexhost/web-ui-shell", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-web": "*", + "@deepseek-ai/dsh-client-ui-theme": "*", + "@vitejs/plugin-react": "^4.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "^6.0.0" + } +} diff --git a/apps/web-ui/apps/web/product-isolation.ts b/apps/web-ui/apps/web/product-isolation.ts new file mode 100644 index 000000000..6b54d4258 --- /dev/null +++ b/apps/web-ui/apps/web/product-isolation.ts @@ -0,0 +1,121 @@ +/** Vite adapters for recording product chunk, asset, CSS, and worker inputs. */ + +import type { Plugin } from "vite"; +import { WebProductBundleIsolation } from "../../scripts/web-product-bundle-isolation.ts"; + +const DEPENDENCY_ANALYSIS_PLUGIN = "dsh-browser-dependency-analysis"; + +/** + * Identify a dependency-disclosure walk that deliberately leaves third-party imports external. + * @returns Analysis intent that refuses builds which can write output. + */ +export function browserDependencyAnalysis(): Plugin { + return { + name: DEPENDENCY_ANALYSIS_PLUGIN, + apply: "build", + configResolved(config) { + requireNonWritingAnalysis(config.build.write); + }, + }; +} + +function requireNonWritingAnalysis(write: boolean): void { + if (write !== false) throw new Error("browser dependency analysis requires build.write: false"); +} + +/** + * Reject experimental inputs reachable from the built default Web page. + * @param repository - repository root supplying package ownership. + * @param webRoot - Vite root containing index.html and public assets. + * @returns Build plugins that preserve Vite's output and fail before publication on a violation. + */ +export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] { + const inputs = new WebProductBundleIsolation(repository, webRoot); + let dependencyAnalysis = false; + return [ + { + name: "dsh-product-web-chunk-inputs", + apply: "build", + generateBundle: { + order: "pre", + handler(_options, bundle) { + if (!dependencyAnalysis) inputs.captureChunks(bundle); + }, + }, + }, + { + name: "dsh-product-web-bundle-isolation", + apply: "build", + enforce: "post", + config(config) { + const renderBuiltUrl = config.experimental?.renderBuiltUrl; + const workerPlugins = config.worker?.plugins; + return { + experimental: { + renderBuiltUrl(filename, context) { + inputs.assetReference(filename, context.hostId, context.type); + return renderBuiltUrl?.(filename, context); + }, + }, + worker: { + plugins: () => [ + ...(workerPlugins?.() ?? []), + { + name: "dsh-worker-build-inputs", + generateBundle: { + order: "post", + handler(_options, bundle) { + inputs.workerBundle(bundle, this.getWatchFiles()); + }, + }, + }, + ], + }, + }; + }, + configResolved(config) { + dependencyAnalysis = config.plugins.some( + (plugin) => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN, + ); + if (dependencyAnalysis) { + requireNonWritingAnalysis(config.build.write); + return; + } + const cssPlugins = config.plugins.filter((plugin) => plugin.name === "vite:css"); + const css = cssPlugins[0]; + if (cssPlugins.length !== 1 || css?.transform === undefined) { + throw new Error("Web product isolation: Vite CSS input instrumentation is unavailable"); + } + const transform = + typeof css.transform === "function" ? css.transform : css.transform.handler; + css.transform = { + ...(typeof css.transform === "function" ? {} : css.transform), + handler(code, id, options) { + inputs.cssTransform(id); + const context = new Proxy(this, { + get(target, property) { + if (property === "addWatchFile") + return (file: string): void => { + inputs.cssDependency(id, file); + target.addWatchFile(file); + }; + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return transform.call(context, code, id, options); + }, + }; + }, + buildStart() { + inputs.reset(); + }, + generateBundle: { + order: "post", + handler(_options, bundle) { + if (!dependencyAnalysis) inputs.verify(bundle, (id) => this.getModuleInfo(id)); + }, + }, + }, + ]; +} diff --git a/apps/web-ui/apps/web/public/apple-touch-icon.png b/apps/web-ui/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8f3593311621d33700f37cb2326ccef2abb0fe4d GIT binary patch literal 1732 zcmd6o`#;lr9LJShPBB8|@~x$dqa&HQv?i0>rl!a(SzAPM65AQgUAatd!{)Y9E}fG+ zQV0u0X}2>DbFGymTbXiMrM8;O&bPnfoF6{#&+GkuKYw~(9`79QV;(8gT7PJozA`dBDDBE%)F!GA>8){c>Dhg=!D!oPDBbL8*Z z;Vqmb(gi!1G(xeEBCRx;6g=yHl>WAywBRV8AgSt`VGOpH`K>Wvuh~RAFMfQ-r$ZeRW0x)o`mgWT*@`@fK$yc!Ih4TcfopcNpQbWuyl0fq zZ!%dx;EPr!b3JXJrNxB?L;nC-r0+=mXEBerZ0q2VG||~sUte!&X_@{G4tPXIFYxI+ z8#tWe6xEYXI|GaV@kh({94+3^ptsXmZhkQkgvDZ@p6B4y<>lpxN92O#cm*0xCmL+p zbT6nS(eKG&C_fBNec$I4)J~|$*Gt(|z>lNuk9mW}+Q-}{rY;DZeT&d$9uUb6P6wv6 z9hSQ53Lk-c9Pi`1Sjx^r>Gg*JQ-t{5l07yN)R`0ZW(u@9YZe&j{7%uHwXw0;jr&@R zpoj_Tg-dd2BevrNmY^`SG3)C1k^9tlT6zyb##bHufM-V4x5Ls_leszqMj?wR=4#7R zU{B%)auJ!RhOnc+VW_Ox98#mxC)|`hnC;b&=Q6`Gw&$9D4beC%)>SlJ?jmZYRGfE98eNc)T%gul{g!+JalVDM`)`p;s z8sS2IGGGx<(qVOr$-E>Pm{@zg~i>wOT@X8i-qxROlCkpD%b$4+l*yMWNK=+ zcKDxlb<$UDx4KtaN85)qZoFE++$H~X5P+R%Mznt3oFnI%oN`+YS2!N`pYA`c-v49JuTr)KMJ>1gWe$Z%ZpJ{r0ve<8EN%frwrH{$ph6o6=0;KoW&Q*=&W|TlQgg z*1~G?ygh^^4~Qzdb}exlL)Y#bQ@$@DF1(HjEs>J-W37w}ZX(CN%I3#KqT1G0TkC#C z$cx6w!Pm)dn4MH|rSB6ales7nXso?5HwTpCO|J$gB_+AA1a@}#;0JfLMviDDB>(dFF#N`AzHCrGKZGAj^+LjC6vQdKFR WsV!&WW+h-m#nbJW>;1!L(*Fg{Un&Fu literal 0 HcmV?d00001 diff --git a/apps/web-ui/apps/web/public/favicon-dark.svg b/apps/web-ui/apps/web/public/favicon-dark.svg new file mode 100644 index 000000000..70ac67657 --- /dev/null +++ b/apps/web-ui/apps/web/public/favicon-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/web-ui/apps/web/public/favicon.svg b/apps/web-ui/apps/web/public/favicon.svg new file mode 100644 index 000000000..9d2c7bd33 --- /dev/null +++ b/apps/web-ui/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web-ui/apps/web/public/icon-192.png b/apps/web-ui/apps/web/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a51963c115d52fd727799b9d368d736349b1e2fb GIT binary patch literal 1872 zcmd7T`#alL90%|;iA&OG`I2f;bfs32surELG{j1zku0vA+uUijnd#C6Arxb4N^OcJ zGSUroSC_^mDn#@aVMVFAtTQ1ks#z8~W#9f6{o$PRJg@iX{BWMcX%O69eZS+ro(?-#6m8PGX0{$Xhd7Mx?%nw)p2+RQ znU`29*b7`@6*{+bl*Dc=#a3NyZeNHAVufn7aMR-)yUJVL-4gYG`f5`uMUeJ$Zo|-{ zzOSbR%~%yzdFp#*iubGxeNCPDqwT8FMb^)l`IA8SOGDeg4uXWk(a=of4%ci}xf_mh zz`R;l*H>HH`s3mLfnUX9@%fgR=%`=GoLl1~BZ*t7c;@>K9?vwx&JKLYK%qb&Se`l) z@*r)xDZHPzxz*c?V=x$rYx4K+-$RclX#8AOCX6L7Wg+x=bl0m_)wi%_W22kaP2nXn zb>>6?mz(-}g8Y|+&6GGG5C~#%&-CQv68j<)hSF$F-@gCTA>rm9Pa+~B_8bYu;m-y# zaznf5mo8tv@r&W0L~^UBC^^i;>8;S5UkCRM4AdP~*+*p+v8Yt41O$@e*kxwAiFba5*9*5aU){GjULT~C%k(vjjFNNw`Nqb^$B!MO`dIyt z`FxoFIgO#ycK_Kh<)}eHRZWexm0T5@Q28!i1YN=7#s0Y6W-h0b|IuRNuSer0{x~;$ zgPmc)aDk7FriK0dt~)yrI!=9;K|XSHKgf)67+`cyn!(D-K_wkDuQ*W4MR>+Nv?zvx zJlG8K^+a2ciCh%OsDKaEqGed+%8alpb>md5jE620$%+~K5YVWV)N*t2;9!8i>Ep+b zfwHOlwAkqAS;1OyFKy&&@$?+Cx~C`f3IG6JUOfQ(6R#mplpkKXAmyB9+yhIJ9>!D~ zfj14-u{GZDIs|-aDcpb+cWUAJ#W?A0bz?}^A<=C|ENSk!9 z-ucYkuSR}qq1;$lh{>4cTa{DlH4n=W8xQgdS#EyhprD{j%ZI_>KsjYBNj_B8*Ecq^ zhto?N(wZMjT7TopbmsB-yKTdIY_zkco~?^g$aH%9A_|2{m}qLQQFyqrOnPA17v|q8 zCR>+aL&#k>YIl5*ujj4l?o1aL>Z(d!2I58|*+dXshv4qcjvaa+{*42%RK=lP+o60@`2@0>&p&Ok9pveZmr!uo*%pM z3$M7ixU}>pkLMEebZ=f(k~~d0I#zbKpI=ECC9 z>VR0Bt3x7@I45XJi|hEOO>fVP)CM#j^OOC6ej?*Kg-{A;Ri?71rN_zK_2|JH%LhGf z7#P>V=h+5)-51G#zP>O;Hqjh8z|%W;)&Px`me}DFngB}jyNf_w!5Q`I7pj5vHTv$$ zKeE|a3}&B$bttL{7+xH?V;NXcZ{_3^|ESP1VoIE+Z)h88qZx=;?91K>2t=}^Y`SDc z+Ah{On?}a$XlrftV)eCtS;FC9j+mFH$6wO1DSuP%AfKPl-C60)bfCVXlJ5b-88EHJ z;EtE_f$-_OadC0FhQY#Q75H<2cz>v3Y3Y&7R*=5EmDGq*y9AW0`#kM{&We6?x5jv8 zW|3^GRIFL0Z3*iuLPowzN@+Blr(dWNF>jVZ|5oSa$V_Z8jUPKk+$5t2I| z`SjJTZ(fb(o1ICsanv353F-cBB;`xBWVkpq+3sck`LM9C$VjkzajY>9T72@4W^Fu@ z8#_cl#;mRBO*}|_Gq>8%(D3RxEtdd?7saInXy)ww`1<{Z76h`redWJ%H^I56`C%Z! YAd_A$XT1p*T0nsiKKFKSaQiCre~6|<2mk;8 literal 0 HcmV?d00001 diff --git a/apps/web-ui/apps/web/public/icon-512.png b/apps/web-ui/apps/web/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d5bae856023e065db085bbda5e033bee20343c48 GIT binary patch literal 5202 zcmeHL`6JZZ`#$nN5#ct#Q*@}rpHJp z0KmXc3=mm^5ZS|p07wE;(n0IMwDG=>J6-;-8h*85w0!I|x-~?knb*(WdVELwLAK9c z1>6RAx2zafrwc6~0$b@B=aN4WEZDL5-@R<wETcC>HEi%QLzEpYXT~ErYJ;>Fcg< z#r?mo`lRmLG%k3R!I)a)Pz`yPmjw7{sMEod!yGP`I&ffq{xA;wnll5}l>s;?3WyX8 zkW2&syYt`Szt;Hw1p^a**Y?`AK;!H94@w8BWUT(%cu&<+<#_uK#xx2g9&Sh&lh0ULQ6|!jAmv39_^f|}kX)lpF z{ycksz@#~--T|1u*qZ#dpyBk?;FiiO-|J(y%+?7=xPxc=HIS39p$cIOr5AIz>-mo+ z?w_oD)kEG)bTdHCPedujJsJ05AF>z?`t!(bpAF#ZQJ}@F@E(ZV9(;MQF8sG$vS2*! zcjAM6{MFeF27_p#dRGMMI72|k*URdR*fN5H>rf<5R#r|vLwmS@#bRNn_3-HUY3i~I z^A!KbR?9$g9p=uB-VD&~3f?IL`!5$Wi;RYiLXw1fsT?oSC=w`n=Jq)!F$CIh ze#>4F6F5Hm_NFzqNbsO4loXfK%ZX?QQHS9?bgA`LS#VH<_#Y~FjGaf(0g7<{-45lR zvqK-(hGt~A4EndezTR3#7O+*o7W-#s&Z;Shqf$6D)^KIy&!;wpW`d6qip?v|VzI?s zP5SmSR8!KmHo!31_;;H;=5(oA`jnF%SECV?c$DO9M;QS(ySW^J4+r`2lm{un6GON5nVpzdHWd6o-QxRcE{_SfHr# z+YHt(0z6xnB-D~}ymKn9Xe&m=+&~Z6wh>&NSRQs(og@ruXA*S(o_Jc>6`^98_T_0Z zqo*C=A9E!il-mO-(A$JNJ3C{y610$NJn;KC+s3&3T2LlOaXN9ct8*sSac}S( z$Gh^;X6MTCHF_P-0PTW;E43pP{lQP$K8B%}S zRdr=W({8%GIcXQPv+n$St!^?yZDqx~x6lv?ZyCnLilvkIr z5&Z)K%uEb~en~W|gjeSzg&u;?k3+vb=MgI;(Fh0u8&O3F`cmkdXFTGf6#54QNCX%N zWJ$E9lt+}(|;SuAd&@>2S9BK$*jcf{x@7#8ZHYAKcdka6s@#xmP0{-f< z@#p}9wx%;=ryzL3SW%*e9ZewwLkVNaKM_NoXpKo&#{lbudExwHAisV^fVmSeuZ%p3 zY=sWz^Z570o|f>$#Y=G%I}Al|DzIUHEvIivI*hx^n1iP`Vii1K8BkcjZ+~>y>TD9P znarSa`EW{2S>o8P)6)VTARC^;x`jK`)l@F z64;5Pq*!@lUW@j{#h;%qzQ47}yLS0PmRTCm>VTI~ZpO-T^?;GqCW;%221)D{Z^hd( zlX^%|-if-*wjvvOG4oGGyH6XUv0HsxKCu{8M`AaB`cm1rGThvFS4o~4c${s1|A`RN z*qHd+jn!RF59c#=+`j!8uR&NElu-7$0IQDKp}wG|oicc>VJXTHo1#^Vul*e7hx5gS zgo2 z65lsfX{NZo^>uFsJVAJIuS7g>J=YDZ#>}+{>Z4qPdNCG2Q>F+T9(8 zZ*C3xaW@*14@ucFEoCI=pJL_l)?I9ULvpGfvtn7M3x8m*?x$I}p`4zldC*B#Znb%; zp89dSBwXDA&UnJe*?(%I<|5;$~sf!%XOHaR+T+yL?{ zXJH~$wPf$Pdgb60aQ^Jij_jTaZ*tIy$>9~7Nq`q?jY&a}#y4Zd6}uf4W)tPIY5CO? zs^L2hkK&BXfmTW26|3gRj$+#T>i+p6((AlHn378CWHihP6np-?ux^iJ&!eJ&8?_Z+ z%~XALB0Zq>_mj+pQks~;uzU?&yC!OjB&;KSKkCwWvzb;&q{^9D{qjGt|IB;#R{qRM zy=2Cebmt~asNOK7g_h`G6|B0PIP)^jp?h6+b+6zNRYA=^;I3BiI?T=NzrzlAD|nhe zmrU+24E=svU&>@*ytGYaO}mNgWGCp8Su2%4ifHdsVJQDdFt(7kfr*ME8$N(`qX@>x z==X)rs^>-CaOCU7{72nPnxzzPcp@(_D^ir`AIh6`uKapa&MN-pY-`ZP3<7?9CAWf8 z;XB+)+N*y03v?Kyf5AYzllJL;9noEmi}RD*?h<%$jeU#JT>dICW${)0)QiijOIM<^ z@_HGB5&AX$T(0rX;1nEDmpgKFF>EH`v;L*t#HyyDkEvvXvsLrnzCW{*Rm4W}pB9z# z{u=3H(7dvO;X{F0Jylyh6Z8CB!{kTYo^tO2Dvb5?umu84Ov@TCSosQXHyEW~+h8W$ z@IjLi`pG1jR2%$Y=dN~@?*IsJ)_MWS=uB2^?WM`ym!%idS-Ip)4Q_lAO>ME)x6QV8 zrp)YjDZcBE#=B&rvVwrA{^*Fxk*pX7pBo|tqdIhpVc_(SrUVsoh9S(L=Hi~^blU27 zdHBw%I^U#ZVez{Yq`r5TPWyejVe7O!5UuFy?!J#ZCj^N|2`ry{8NS@VSR1g^13BTi zRRXKPz9+{gefVc?rm0_RMXmoiHZ??XfXe3#$osZjhovUT28+(DW%9k9F%pK;btEUq zH)Hs;J?GfXNzv)>s)AQ`VE@pD-^=pIRJ%INif~cbM@`aRhaR(+V zn>HQ3x!%;T!mG!Lw)(3fdAEH>=Zkm4OkWc)IJE#@vm2FeO33Ne%nVdjRXu$8@ZI*m zs{B3JP9GlXdUO@om%`@^Nn@>}a0w&uZ*%3}R@49OwDE-j;PcxIUt7Y?pwJo(2!P^W W!ld8QTwy~F08 { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", (event) => { + let message = {}; + try { + message = event.data ? event.data.json() : {}; + } catch { + message = { title: "CodexHost", body: event.data ? event.data.text() : "" }; + } + event.waitUntil( + (async () => { + // Skip the notification when the user is already looking at CodexHost. + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const visible = windows.some( + (client) => client.visibilityState === "visible" && client.focused, + ); + if (visible && message.kind === "turn") return; + await self.registration.showNotification(message.title || "CodexHost", { + body: message.body || "", + tag: message.tag || undefined, + renotify: Boolean(message.tag), + icon: "icon-192.png", + badge: "icon-192.png", + data: { url: message.url || "./", sessionId: message.sessionId }, + requireInteraction: message.kind === "approval" || message.kind === "question", + }); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const target = new URL(event.notification.data?.url || "./", self.registration.scope).href; + event.waitUntil( + (async () => { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if (client.url.startsWith(self.registration.scope)) { + await client.focus(); + client.postMessage({ + type: "codexhost/open-session", + sessionId: event.notification.data?.sessionId, + }); + return; + } + } + await self.clients.openWindow(target); + })(), + ); +}); diff --git a/apps/web-ui/apps/web/src/main.ts b/apps/web-ui/apps/web/src/main.ts new file mode 100644 index 000000000..8cca8a3e2 --- /dev/null +++ b/apps/web-ui/apps/web/src/main.ts @@ -0,0 +1,61 @@ +/** Browser entry for the Web client. */ +import "@deepseek-ai/dsh-client-ui-theme/brand-font.css"; +import { AppWebEntry, applyIndexInjections } from "@deepseek-ai/dsh-client-web"; + +interface DesktopBootGlobal { + dshDesktopBoot?: { + failed(message: string): Promise; + ready(): Promise<{ + injections: Parameters[0]; + streamBaseUrl: string; + }>; + }; +} +const desktop = (globalThis as DesktopBootGlobal).dshDesktopBoot; +const reportFailure = (reason: unknown): void => { + if (desktop === undefined) throw reason; + void desktop + .failed(reason instanceof Error ? reason.message : String(reason)) + .catch(console.error); +}; + +try { + const el = document.getElementById("root"); + if (el === null) throw new Error("web app: missing #root"); + const entry = new AppWebEntry(el); + if (desktop !== undefined) { + const gate = (globalThis as { __DSH_BOOT_READY__?: PromiseWithResolvers }) + .__DSH_BOOT_READY__; + if (gate === undefined) throw new Error("desktop web: boot readiness is missing"); + void desktop + .ready() + .then(async ({ injections, streamBaseUrl }) => { + const transport = globalThis as { + __DSH_TRANSPORT__?: { ownsHost: boolean; streamBaseUrl: string }; + }; + transport.__DSH_TRANSPORT__ = { ownsHost: true, streamBaseUrl }; + await applyIndexInjections( + injections, + (src) => + new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.onload = () => { + resolve(); + }; + script.onerror = () => { + reject(new Error(`desktop web: failed to load ${src}`)); + }; + document.head.append(script); + }), + ); + gate.resolve(); + }) + .catch((error: unknown) => { + gate.reject(error); + }); + } + void entry.run(desktop === undefined ? undefined : reportFailure); +} catch (reason) { + reportFailure(reason); +} diff --git a/apps/web-ui/apps/web/src/node-module-stub.ts b/apps/web-ui/apps/web/src/node-module-stub.ts new file mode 100644 index 000000000..bffb6e247 --- /dev/null +++ b/apps/web-ui/apps/web/src/node-module-stub.ts @@ -0,0 +1,12 @@ +/** + * Browser stand-in for `node:module`. `createRequire` is unreachable in the + * configured loader path and fails loud if that assumption changes. + */ + +/** Fail if browser boot reaches Node's module loader. */ +export const createRequire = (): never => { + throw new Error("node:module is not available in the browser"); +}; + +/** Type-only peer for the vendored loader. */ +export type LoadHookContext = never; diff --git a/apps/web-ui/apps/web/src/preview.ts b/apps/web-ui/apps/web/src/preview.ts new file mode 100644 index 000000000..07fcecc7d --- /dev/null +++ b/apps/web-ui/apps/web/src/preview.ts @@ -0,0 +1,16 @@ +/** + * Worker-preview bootstrap: the one module preview.html adds ahead of the + * stock entry tag. The runtime's optional source stage owns the pre-Cordis + * chooser; the unchanged Host connector then owns the Worker handshake. + * Everything after those calls is the served startup chain verbatim. + */ +import DshWorker from "@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker"; +import { + chooseWorkerHostSource, + connectWorkerHost, + IMAGE_FILE_NAME, +} from "@deepseek-ai/dsh-experimental-webworker-runtime/client"; + +const image = `preview/${IMAGE_FILE_NAME}`; +const source = await chooseWorkerHostSource({ image }); +await connectWorkerHost(new DshWorker({ name: "dsh-host" }), { image, overlays: source.overlays }); diff --git a/apps/web-ui/apps/web/src/vite-env.d.ts b/apps/web-ui/apps/web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/web-ui/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts new file mode 100644 index 000000000..0fe35acad --- /dev/null +++ b/apps/web-ui/apps/web/stress-tests/reasoning-chunks.stress.ts @@ -0,0 +1,269 @@ +/** + * Opt-in browser stress reproduction for reasoning-stream renderer stalls. + * A test-owned model adapter emits 100,000 individual chunks through the real + * Host, Gateway, and browser carriers; the test measures event-loop and + * scheduled-interaction delay while the assembled React surface keeps a + * collapsed Think row live. + */ +import type { Browser, Page } from "playwright"; +import { chromium } from "playwright"; +import { expect, it, onTestFailed } from "vitest"; +import { LlmAdapter, type GenerateOptions, type StreamChunk } from "@deepseek-ai/dsh-llm"; +import type {} from "@deepseek-ai/dsh-agent-default-model"; +import { launchWebScaffold, watchConsole, type WebScaffold } from "../tests/scaffold.ts"; +import { + connectFreshWorkspace, + newEnglishPage, + saveFailureShot, + writeComposerDraft, +} from "../tests/support.ts"; + +const CHUNK_COUNT = 100_000; +const CHUNKS_PER_INTERVAL = 128; +const CHUNK_INTERVAL_MS = 16; +const MAIN_THREAD_DELAY_BUDGET_MS = 250; +const PROVIDER = "reasoning-stress-test"; +const MODEL = "reasoning-storm"; +const MARKER = `REASONING_STRESS_COMPLETE:${String(CHUNK_COUNT)}`; + +interface ReasoningChunkStormState { + chunkCount: number; + chunksPerInterval: number; + intervalMs: number; + emitted: number; + marker: string; + emitting: boolean; +} + +/** Externally paced model stream whose counters are observed outside the browser. */ +class ReasoningStressAdapter extends LlmAdapter { + private finishStream!: () => void; + private resolveStarted!: () => void; + private resolveCompleted!: () => void; + private releaseBatch: (() => void) | undefined; + private resolveBatch: (() => void) | undefined; + private batchInFlight = false; + private finishRequested = false; + private readonly finishGate = new Promise((resolve) => { + this.finishStream = resolve; + }); + readonly started = new Promise((resolve) => { + this.resolveStarted = resolve; + }); + readonly completed = new Promise((resolve) => { + this.resolveCompleted = resolve; + }); + private emitted = 0; + private emitting = false; + + finish(): void { + this.finishRequested = true; + this.releaseBatch?.(); + this.finishStream(); + } + + async emitNextBatch(): Promise { + if (!this.emitting || this.releaseBatch === undefined) { + throw new Error("reasoning stress adapter has no batch awaiting release"); + } + if (this.batchInFlight) + throw new Error("reasoning stress adapter accepts one batch release at a time"); + this.batchInFlight = true; + const delivered = new Promise((resolve) => { + this.resolveBatch = resolve; + }); + const release = this.releaseBatch; + this.releaseBatch = undefined; + release(); + await delivered; + } + + state(): ReasoningChunkStormState { + return { + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: this.emitted, + marker: MARKER, + emitting: this.emitting, + }; + } + + override async *stream(options: GenerateOptions): AsyncIterable { + if (this.emitting || this.emitted !== 0) + throw new Error("reasoning stress adapter accepts one model call"); + this.emitting = true; + const parts: string[] = []; + yield { type: "block-start", index: 0, blockType: "reasoning" }; + while (this.emitted < CHUNK_COUNT) { + await new Promise((resolve) => { + this.releaseBatch = resolve; + if (this.emitted === 0) this.resolveStarted(); + if (this.finishRequested) resolve(); + }); + if (this.finishRequested) break; + options.signal?.throwIfAborted(); + const end = Math.min(this.emitted + CHUNKS_PER_INTERVAL, CHUNK_COUNT); + while (this.emitted < end) { + const text = + this.emitted === CHUNK_COUNT - 1 + ? `\n${MARKER}` + : this.emitted % 64 === 63 + ? "推理\n" + : "推理"; + parts.push(text); + yield { type: "reasoning-delta", index: 0, text }; + this.emitted += 1; + } + this.batchInFlight = false; + this.resolveBatch?.(); + this.resolveBatch = undefined; + } + this.emitting = false; + if (this.emitted === CHUNK_COUNT) this.resolveCompleted(); + await this.finishGate; + options.signal?.throwIfAborted(); + yield { type: "block-end", index: 0, block: { type: "reasoning", text: parts.join("") } }; + yield { type: "finish", reason: { kind: "stop" } }; + } +} + +interface StressProbe { + intervalId: number; + intervalMs: number; + lastTickAt: number; + maxDelayMs: number; + samples: number; + interactionDueAt: number; + interactionHandledAt: number | null; +} + +interface StressWindow extends Window { + __reasoningStressProbe?: StressProbe; +} + +it("keeps the browser responsive while rendering 100,000 reasoning chunks", async () => { + let scaffold: WebScaffold | undefined; + let browser: Browser | undefined; + let page: Page | undefined; + const adapter = new ReasoningStressAdapter(); + try { + scaffold = await launchWebScaffold(); + scaffold.ctx.effect( + () => scaffold!.ctx.llm.registerAdapter([PROVIDER], adapter), + "reasoning stress adapter", + ); + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL }); + browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== "1" }); + page = await newEnglishPage(browser); + const activePage = page; + const tripwire = watchConsole(activePage); + onTestFailed(() => saveFailureShot(activePage, "web-stress-reasoning-chunks")); + await activePage.goto(scaffold.authenticatedUrl, { waitUntil: "load" }); + await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }); + await connectFreshWorkspace(activePage, scaffold.workspaceCwd); + + await activePage.evaluate(() => { + const intervalMs = 50; + const now = performance.now(); + const probe: StressProbe = { + intervalId: 0, + intervalMs, + lastTickAt: now, + maxDelayMs: 0, + samples: 0, + interactionDueAt: now + 1_000, + interactionHandledAt: null, + }; + probe.intervalId = window.setInterval(() => { + const tickAt = performance.now(); + probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs); + probe.lastTickAt = tickAt; + probe.samples++; + }, intervalMs); + document.body.addEventListener( + "reasoning-stress-interaction", + () => { + probe.interactionHandledAt = performance.now(); + }, + { once: true }, + ); + window.setTimeout(() => { + document.body.dispatchEvent(new CustomEvent("reasoning-stress-interaction")); + }, 1_000); + (window as StressWindow).__reasoningStressProbe = probe; + }); + + const settled = scaffold.whenTurnSettled(540_000); + const input = activePage.locator("[data-composer-input]").first(); + await writeComposerDraft(activePage, input, `Render ${String(CHUNK_COUNT)} reasoning chunks.`); + await input.press("Enter"); + await adapter.started; + + await adapter.emitNextBatch(); + const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last(); + await liveThink.waitFor({ timeout: 60_000 }); + await activePage.evaluate( + (intervalMs) => + new Promise((resolve) => { + window.setTimeout(resolve, intervalMs); + }), + CHUNK_INTERVAL_MS, + ); + while (adapter.state().emitted < CHUNK_COUNT) { + await adapter.emitNextBatch(); + await activePage.evaluate( + (intervalMs) => + new Promise((resolve) => { + window.setTimeout(resolve, intervalMs); + }), + CHUNK_INTERVAL_MS, + ); + } + await adapter.completed; + await expect + .poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }) + .toContain(MARKER); + + const browserReport = await activePage.evaluate(() => { + const win = window as StressWindow; + const probe = win.__reasoningStressProbe; + if (probe === undefined) throw new Error("reasoning stress metrics unavailable"); + window.clearInterval(probe.intervalId); + const interactionDelayMs = + probe.interactionHandledAt === null + ? null + : probe.interactionHandledAt - probe.interactionDueAt; + return { + maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs), + interactionDelayMs, + heartbeatSamples: probe.samples, + }; + }); + const report = { ...adapter.state(), ...browserReport }; + process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`); + + expect(report).toMatchObject({ + chunkCount: CHUNK_COUNT, + chunksPerInterval: CHUNKS_PER_INTERVAL, + intervalMs: CHUNK_INTERVAL_MS, + emitted: CHUNK_COUNT, + }); + expect(report.heartbeatSamples).toBeGreaterThan(0); + const interactionDelayMs = report.interactionDelayMs; + if (interactionDelayMs === null) + throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`); + expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan( + MAIN_THREAD_DELAY_BUDGET_MS, + ); + expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS); + expect(tripwire.pageErrors).toEqual([]); + expect(tripwire.warnings).toEqual([]); + adapter.finish(); + await settled; + } finally { + adapter.finish(); + await browser?.close(); + await scaffold?.close(); + } +}, 600_000); diff --git a/apps/web-ui/apps/web/tsconfig.json b/apps/web-ui/apps/web/tsconfig.json new file mode 100644 index 000000000..310434a3e --- /dev/null +++ b/apps/web-ui/apps/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/main.ts", "src/node-module-stub.ts"] +} diff --git a/apps/web-ui/apps/web/vite.config.ts b/apps/web-ui/apps/web/vite.config.ts new file mode 100644 index 000000000..005109f16 --- /dev/null +++ b/apps/web-ui/apps/web/vite.config.ts @@ -0,0 +1,274 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import type { Plugin } from "vite"; +import react from "@vitejs/plugin-react"; +import { clientBuildEnvironmentDefines } from "../../scripts/client-build-environment.ts"; +import { productWebBundleIsolation } from "./product-isolation.ts"; + +const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)); +const STANDALONE_ERROR = + "apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. " + + "From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. " + + "For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together."; +const DEFAULT_CLIENT_TITLE = "DSH Local Build"; + +/** Escape build-time text before placing it in the HTML title element. */ +function escapeHtmlText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +/** Project the public build title into the initial HTML document. */ +function clientDocumentTitle(): Plugin { + const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE); + return { + name: "dsh-client-document-title", + transformIndexHtml(html) { + return html.replace("DSH Local Build", `${title}`); + }, + }; +} + +/** Keep the redistribution license beside the bundled brand font. */ +function brandFontLicense(): Plugin { + return { + name: "dsh-brand-font-license", + async generateBundle() { + this.emitFile({ + type: "asset", + fileName: "assets/fonts/Montserrat-OFL.txt", + source: await readFile(src("../../packages/client/ui-theme/src/styles/Montserrat-OFL.txt")), + }); + }, + }; +} + +/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ +function rejectStandaloneServe(): Plugin { + return { + name: "dsh-reject-standalone-web-serve", + config(_config, env) { + if (env.command === "serve") throw new Error(STANDALONE_ERROR); + }, + }; +} + +/** + * Emit preview.html beside index.html: the built index page with one module + * script — the worker bootstrap entry — spliced ahead of its entry tag. Both + * pages share every chunk; the extra tag is the only difference, so the + * static worker deployment ships the served page verbatim plus its + * bootstrap. + */ +function emitPreviewPage(): Plugin { + let bootstrapFile: string | undefined; + let write = true; + let written = false; + let outputDirectory = ""; + return { + name: "dsh-emit-preview-page", + configResolved(config) { + write = config.build.write; + outputDirectory = resolve(config.root, config.build.outDir); + }, + buildStart() { + bootstrapFile = undefined; + written = false; + }, + generateBundle(_options, bundle) { + if (!write) return; + for (const item of Object.values(bundle)) { + if (item.type === "chunk" && item.isEntry && item.name === "bootstrap") + bootstrapFile = item.fileName; + } + if (bootstrapFile === undefined) + throw new Error("vite: preview bootstrap entry missing from the bundle"); + }, + writeBundle() { + written = true; + }, + async closeBundle() { + if (!write || !written || bootstrapFile === undefined) return; + const page = await readFile(resolve(outputDirectory, "index.html"), "utf8"); + const anchor = page.indexOf('`; + await writeFile( + resolve(outputDirectory, "preview.html"), + `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`, + ); + }, + }; +} + +/** + * Vendor-chunk membership, by exact npm package name — the heavy render + * families (math, highlight, markdown) that change only on dependency bumps. + * Only packages workspace code imports DIRECTLY need listing: their private + * transitive dependencies (oniguruma machinery, character tables, …) are + * imported solely by these and rollup's chunk coloring pulls them into + * vendor automatically. A dependency shared with index-side code falls back + * to index — a few kB of dilution, never a correctness problem. Anything not + * listed (react family, the vendored cordis workspace, tiny helpers like + * anser/clsx, all workspace code) stays in the default `index` chunk, so + * editing shell code re-hashes only index and returning clients keep the + * cached vendor chunk. + * + * Every member must be React-free. A package that + * imports react/jsx-runtime must never be listed — rollup folds a module + * shared between the entry and a manual chunk into the manual chunk, so one + * react-importing member would drag the single shared react copy into + * vendor. The React side of markdown/math rendering is workspace code and + * rides index. + */ +const VENDOR_PACKAGES: ReadonlySet = new Set([ + // math + "katex", + // syntax highlight (@shikijs/langs is handled separately below — + // lazy grammars must not land here) + "shiki", + // markdown parse pipeline (micromark/mdast; the incremental React renderer + // over it is workspace code) + "mdast-util-from-markdown", + "mdast-util-gfm", + "mdast-util-math", + "micromark-core-commonmark", + "micromark-extension-gfm", + "micromark-extension-math", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-classify-character", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types", +]); + +/** + * Boot grammars statically imported by ui-primitives' highlight.ts + * (`@shikijs/langs/typescript` → `dist/typescript.mjs`, etc.). They live in + * the same package as the lazy read-card grammars, but unlike those they are + * part of the initial load and belong in the vendor chunk; the lazy ones must + * stay unassigned so each keeps its own on-demand chunk. + */ +const BOOT_GRAMMAR_FILES: readonly string[] = [ + "dist/typescript.mjs", + "dist/shellscript.mjs", + "dist/json.mjs", +]; + +/** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */ +const FONT_EXTENSIONS: readonly string[] = [".woff2", ".woff", ".ttf"]; + +/** + * npm package name of a resolved module id: the segment after the last + * `node_modules/`. pnpm nests the real package under an inner node_modules. + */ +function npmPackageOf(id: string): string | undefined { + const parts = id.split("/node_modules/"); + if (parts.length === 1) return undefined; + const [first, second] = parts[parts.length - 1].split("/"); + if (first.startsWith(".")) return undefined; // .pnpm store segment, not a package + if (first.startsWith("@")) return second === undefined ? undefined : `${first}/${second}`; + return first; +} + +export default defineConfig({ + // Relative asset URLs: preview.html mounts the same output under any base + // directory, and the served index resolves identically from the site root. + base: "./", + plugins: [ + rejectStandaloneServe(), + clientDocumentTitle(), + brandFontLicense(), + react(), + productWebBundleIsolation(src("../.."), src(".")), + ], + build: { + // The worker bootstrap holds its page at top-level await; Vite's default + // `modules` target (es2020-era) rejects that syntax. + target: "es2022", + sourcemap: true, + rollupOptions: { + input: { + index: src("./index.html"), + // Standalone entry, not an index.html script tag: Vite folds every + // module tag of one page into a single synthetic entry, and only a + // separate input keeps the shared page chunks bootstrap-free. + }, + output: { + // The worker-preview surface groups under dist/preview/ (the page + // itself stays at dist/preview.html), so the published payload can + // exclude it as one directory. + entryFileNames(chunk): string { + return chunk.name === "bootstrap" + ? "preview/[name]-[hash].js" + : "assets/[name]-[hash].js"; + }, + // Output layout: the two main chunks stay at assets/ root; lazy + // @shikijs/langs grammar chunks group under assets/langs/; fonts + // (all KaTeX faces referenced by vendor.css) group under + // assets/fonts/. Sourcemaps need no arrangement: rollup writes each + // .map next to its js and references it by bare relative filename. + chunkFileNames(chunk): string { + // Grammar chunks are recognized by their member modules, not the + // facade: shared embedded-grammar chunks (e.g. html+javascript, + // split out because php/ruby/mdx embed them) have no facade at all. + // index and vendor are excluded by name — vendor legitimately + // carries the three boot grammars. + if (chunk.name === "index" || chunk.name === "vendor") return "assets/[name]-[hash].js"; + const isLangChunk = chunk.moduleIds.some((id) => + id.includes("/node_modules/@shikijs/langs/"), + ); + return isLangChunk ? "assets/langs/[name]-[hash].js" : "assets/[name]-[hash].js"; + }, + assetFileNames(asset): string { + const fileName = asset.names[0] ?? ""; + const isFont = FONT_EXTENSIONS.some((ext) => fileName.endsWith(ext)); + return isFont ? "assets/fonts/[name]-[hash][extname]" : "assets/[name]-[hash][extname]"; + }, + manualChunks(id: string): string | undefined { + const pkg = npmPackageOf(id); + if (pkg === undefined) return undefined; // workspace + vendored cordis: index + if (pkg === "@shikijs/langs") { + return BOOT_GRAMMAR_FILES.some((file) => id.endsWith(`/${file}`)) + ? "vendor" + : undefined; + } + return VENDOR_PACKAGES.has(pkg) ? "vendor" : undefined; + }, + }, + }, + }, + worker: { + // The preview worker rides dist/preview/ with the rest of that surface. + rollupOptions: { output: { entryFileNames: "preview/[name]-[hash].js" } }, + }, + resolve: { + // One instance per shared npm identity: a bare specifier otherwise resolves + // from the importer's directory, so a diverging range ships a second React + // and splits hook and element identity. Entries are package ids — they cover + // react/jsx-runtime and react-dom/client — and resolve from this package's + // node_modules, so react must stay a devDependency here and any watcher must + // run vite from this directory (scripts/dev-web.ts). Workspace packages need + // no entry: pnpm links each of them to a single directory. + dedupe: ["react", "react-dom"], + // Workspace packages are consumed as built lib products: each resolves + // through its own package.json exports from the importer's directory, and + // CSS still rides Vite's pipeline because the client build preset emits it + // beside the bundle. Plugin packages never enter this graph; they arrive as + // runtime bundles through the client module system. The remaining alias + // browserizes the vendored Cordis Loader's only Node import. + alias: [{ find: /^node:module$/, replacement: src("./src/node-module-stub.ts") }], + }, + define: { + ...clientBuildEnvironmentDefines(process.env), + // vendored loader internal.ts: fromInternal() probes the Node major — + // "0.0.0" takes neither branch, returning undefined (exactly the empty + // internal slot the shell boot fills with the client module loader). + "process.versions.node": '"0.0.0"', + "process.execArgv": "[]", + // vendored loader index.ts: envData falls to its default branch. + "process.env.CORDIS_SHARED": "undefined", + }, +}); diff --git a/apps/web-ui/import-manifest.json b/apps/web-ui/import-manifest.json new file mode 100644 index 000000000..6b310c54a --- /dev/null +++ b/apps/web-ui/import-manifest.json @@ -0,0 +1,150 @@ +{ + "source": "deepseek-harness (codexhost-web fork)", + "version": "0.2.1-alpha.1", + "plugins": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-shortcuts", + "@deepseek-ai/dsh-client-ui-shortcuts", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-general", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-approval", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-brand-official", + "@deepseek-ai/dsh-client-ui-attachment", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-subagent", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-permission-presets", + "@deepseek-ai/dsh-client-ui-plan", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-ui-directory-picker-browse", + "@deepseek-ai/dsh-client-ui-codexhost" + ], + "packages": [ + "@deepseek-ai/cordis", + "@deepseek-ai/cordis-plugin-loader", + "@deepseek-ai/cosmokit", + "@deepseek-ai/dsh-agent-preset-registry", + "@deepseek-ai/dsh-api-account-controller", + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-api-job-controller", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-settings-controller", + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-brand", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-product-analytics", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-shortcuts", + "@deepseek-ai/dsh-client-store", + "@deepseek-ai/dsh-client-ui-approval", + "@deepseek-ai/dsh-client-ui-attachment", + "@deepseek-ai/dsh-client-ui-brand-official", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-codexhost", + "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-directory-picker-browse", + "@deepseek-ai/dsh-client-ui-dockkit", + "@deepseek-ai/dsh-client-ui-input-trigger", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-model-selection", + "@deepseek-ai/dsh-client-ui-permission-presets", + "@deepseek-ai/dsh-client-ui-plan", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-general", + "@deepseek-ai/dsh-client-ui-shortcuts", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-subagent", + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-web", + "@deepseek-ai/dsh-command-feedback", + "@deepseek-ai/dsh-commands", + "@deepseek-ai/dsh-cordis-host-runner", + "@deepseek-ai/dsh-deque", + "@deepseek-ai/dsh-file-reference", + "@deepseek-ai/dsh-goal", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-message-feedback", + "@deepseek-ai/dsh-office-to-pdf", + "@deepseek-ai/dsh-output-retention", + "@deepseek-ai/dsh-permission-presets", + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/dsh-schedule", + "@deepseek-ai/dsh-session", + "@deepseek-ai/dsh-session-reference", + "@deepseek-ai/dsh-spill-policy", + "@deepseek-ai/dsh-subagent", + "@deepseek-ai/dsh-token-meter", + "@deepseek-ai/dsh-typert-protocol", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-user-questions", + "@deepseek-ai/dsh-util-code-language", + "@deepseek-ai/dsh-util-crypto", + "@deepseek-ai/dsh-util-values", + "@deepseek-ai/dsh-util-workspace-path", + "@deepseek-ai/schemastery" + ], + "generatedCodecs": [ + "@deepseek-ai/dsh-agent-preset-registry", + "@deepseek-ai/dsh-api-account-controller", + "@deepseek-ai/dsh-api-job-controller", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-settings-controller", + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-client-product-analytics", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-command-feedback", + "@deepseek-ai/dsh-commands", + "@deepseek-ai/dsh-cordis-host-runner", + "@deepseek-ai/dsh-goal", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-llm", + "@deepseek-ai/dsh-message-feedback", + "@deepseek-ai/dsh-office-to-pdf", + "@deepseek-ai/dsh-permission-presets", + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/dsh-schedule", + "@deepseek-ai/dsh-session-reference", + "@deepseek-ai/dsh-subagent", + "@deepseek-ai/dsh-user-questions" + ] +} diff --git a/apps/web-ui/package.json b/apps/web-ui/package.json new file mode 100644 index 000000000..342cbc05b --- /dev/null +++ b/apps/web-ui/package.json @@ -0,0 +1,19 @@ +{ + "name": "@codexhost/web-ui", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "build": "node --import tsx scripts/build.ts" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.0.0", + "vite": "^6.0.0", + "lightningcss": "^1.32.0", + "immer": "^10.1.1", + "zustand": "^4.4.7", + "tsdown": "^0.22.2", + "tsx": "4.22.4" + } +} diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.d.ts b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts new file mode 100644 index 000000000..2b084287a --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/generated/remote.d.ts @@ -0,0 +1,43 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6163636f756e74 { + ackBonusNotified: (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise> + cancelSignIn: (attemptId: SignInAttemptId) => Promise> + getBalance: (client: AccountClientMetadata) => Promise> + getProfile: (client: AccountClientMetadata) => Promise> + getState: () => Promise> + getUnnotifiedBonuses: (client: AccountClientMetadata) => Promise> + hasRunningAccountTasks: () => Promise> + signOut: (client: AccountClientMetadata) => Promise> + startSignIn: (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise> + watch: (signal?: AbortSignal) => RemoteStreamHandle + watchExpiry: (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never> + } + interface TypertRemoteMap { + 'account/ackBonusNotified': (accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata) => Promise> + 'account/cancelSignIn': (attemptId: SignInAttemptId) => Promise> + 'account/getBalance': (client: AccountClientMetadata) => Promise> + 'account/getProfile': (client: AccountClientMetadata) => Promise> + 'account/getState': () => Promise> + 'account/getUnnotifiedBonuses': (client: AccountClientMetadata) => Promise> + 'account/hasRunningAccountTasks': () => Promise> + 'account/signOut': (client: AccountClientMetadata) => Promise> + 'account/startSignIn': (client: AccountClientMetadata, callbackOrigin: string, loginSource: 'web' | 'desktop') => Promise> + 'account/watch': (signal?: AbortSignal) => RemoteStreamHandle + 'account/watchExpiry': (signal?: AbortSignal) => RemoteStreamHandle<'session-expired', never> + } + interface TypertRemoteNamespaceMap { + 'account': TypertRemoteNamespace$6163636f756e74 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/account-controller/generated/remote.js b/apps/web-ui/packages/api/account-controller/generated/remote.js new file mode 100644 index 000000000..2cb9a6b39 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/generated/remote.js @@ -0,0 +1,456 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema$value ??= z.union([z.literal(null), z.object({ + 'status': z.literal("ready").readonly(), + 'value': z.array(z.object({ + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'balance': z.string().readonly(), +})).readonly(), + 'bonusWallets': z.array(z.object({ + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'balance': z.string().readonly(), +})).readonly(), +}), z.object({ + 'status': z.literal("failed").readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema$value ??= z.union([z.literal(null), z.object({ + 'status': z.literal("ready").readonly(), + 'value': z.object({ + 'id': z.union([z.literal(null), z.intersection(z.string(), z.unknown())]).readonly(), + 'name': z.union([z.literal(null), z.string()]).readonly(), + 'contact': z.union([z.literal(null), z.string()]).readonly(), + 'avatarUrl': z.union([z.literal(null), z.string()]).readonly().optional(), +}).readonly(), +}), z.object({ + 'status': z.literal("failed").readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getState_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getState_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema$value ??= z.union([z.literal(null), z.object({ + 'accountId': z.intersection(z.string(), z.unknown()).readonly(), + 'bonuses': z.array(z.object({ + 'orderId': z.intersection(z.string(), z.unknown()).readonly(), + 'campaign': z.string().readonly(), + 'amount': z.string().readonly(), + 'currency': z.union([z.literal("CNY"), z.literal("USD")]).readonly(), + 'grantedAt': z.string().readonly(), + 'expiresAt': z.string().readonly(), + 'message': z.string().readonly(), +})).readonly(), +})])) +let _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_signOut_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema$value ??= z.object({ + 'version': z.string().readonly(), + 'locale': z.string().readonly(), + 'timezoneOffsetSeconds': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema$value ??= z.union([z.literal("web"), z.literal("desktop")])) +let _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_watch_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watch_result$schema$value ??= z.object({ + 'status': z.union([z.literal("signed-out"), z.literal("credential-stored")]).readonly(), + 'links': z.object({ + 'usageUrl': z.string().readonly(), + 'topUpUrl': z.string().readonly(), +}).readonly(), + 'attempt': z.union([z.literal(null), z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'phase': z.union([z.literal("initializing"), z.literal("waiting-browser"), z.literal("exchanging"), z.literal("committing"), z.literal("succeeded"), z.literal("cancelled"), z.literal("expired"), z.literal("failed")]).readonly(), + 'authorizeUrl': z.string().readonly().optional(), + 'expiresAt': z.number().readonly().optional(), + 'errorCode': z.union([z.literal("expired"), z.literal("no-response"), z.literal("network"), z.literal("protocol"), z.literal("storage")]).readonly().optional(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value +const _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema = () => (_deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema$value ??= z.literal("session-expired")) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-account-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified', + service: 'accountController', + namespace: 'account', + method: 'ackBonusNotified', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'accountId', + wire: 'accountId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountUserId', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_0$schema, + }, + }, + { + name: 'orderId', + wire: 'orderId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountBonusOrderId', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_1$schema, + }, + }, + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/ackBonusNotified:result', + create: _deepseek_ai_dsh_api_account_controller_account_ackBonusNotified_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":55,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/cancelSignIn', + service: 'accountController', + namespace: 'account', + method: 'cancelSignIn', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'attemptId', + wire: 'attemptId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#SignInAttemptId', + create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_cancelSignIn_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":75,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getBalance', + service: 'accountController', + namespace: 'account', + method: 'getBalance', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getBalance_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getBalance:result', + create: _deepseek_ai_dsh_api_account_controller_account_getBalance_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":35,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getProfile', + service: 'accountController', + namespace: 'account', + method: 'getProfile', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getProfile_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getProfile:result', + create: _deepseek_ai_dsh_api_account_controller_account_getProfile_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":26,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getState', + service: 'accountController', + namespace: 'account', + method: 'getState', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_getState_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":19,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses', + service: 'accountController', + namespace: 'account', + method: 'getUnnotifiedBonuses', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/getUnnotifiedBonuses:result', + create: _deepseek_ai_dsh_api_account_controller_account_getUnnotifiedBonuses_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":44,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks', + service: 'accountController', + namespace: 'account', + method: 'hasRunningAccountTasks', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/hasRunningAccountTasks:result', + create: _deepseek_ai_dsh_api_account_controller_account_hasRunningAccountTasks_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":81,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/signOut', + service: 'accountController', + namespace: 'account', + method: 'signOut', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_signOut_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_signOut_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":90,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/startSignIn', + service: 'accountController', + namespace: 'account', + method: 'startSignIn', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'client', + wire: 'client', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountClientMetadata', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_0$schema, + }, + }, + { + name: 'callbackOrigin', + wire: 'callbackOrigin', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:callbackOrigin', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_1$schema, + }, + }, + { + name: 'loginSource', + wire: 'loginSource', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/startSignIn:loginSource', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_startSignIn_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":66,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/watch', + service: 'accountController', + namespace: 'account', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-deepseek-account/types#AccountView', + create: _deepseek_ai_dsh_api_account_controller_account_watch_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":119,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry', + service: 'accountController', + namespace: 'account', + method: 'watchExpiry', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-account-controller#account/watchExpiry:result', + create: _deepseek_ai_dsh_api_account_controller_account_watchExpiry_result$schema, + }, + sourceLocation: {"file":"packages/api/account-controller/src/index.ts","line":97,"column":10}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/account-controller/package.json b/apps/web-ui/packages/api/account-controller/package.json new file mode 100644 index 000000000..c866d38d5 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-api-account-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + } + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/api/account-controller/src/index.ts b/apps/web-ui/packages/api/account-controller/src/index.ts new file mode 100644 index 000000000..9b2ca1dcd --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/src/index.ts @@ -0,0 +1,156 @@ +/** Authenticated Remote operations for account UI consumers. */ +import { Context } from "@deepseek-ai/cordis"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { isRunningAccountTask } from "@deepseek-ai/dsh-deepseek-account"; +import type {} from "@deepseek-ai/dsh-agent"; +import type { + AccountBonusBatch, + AccountBonusOrderId, + AccountClientMetadata, + AccountDetails, + AccountUserId, +} from "@deepseek-ai/dsh-deepseek-account/types"; +import type { AccountView, SignInAttemptId } from "./types.ts"; + +/** Account commands and reconnect-safe state stream. */ +export class AccountController extends TypertRemoteService { + static inject = ["deepseekAccount", "agents"]; + /** @param ctx - Host with the account provider mounted. */ + constructor(ctx: Context) { + super(ctx, "accountController", { namespace: "account" }); + } + /** + * Read the safe account projection. + * @returns current account and attempt state. + */ + @Remote + getState(): Promise { + return this.ctx.deepseekAccount.getState(); + } + /** + * Query display-safe Platform profile data. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns profile outcome, or null when the account grant is absent or changed. + */ + @Remote + getProfile(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getProfile(client); + } + /** + * Query Platform recharge-wallet balances. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns balance outcome, or null when the account grant is absent or changed. + */ + @Remote + getBalance(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getBalance(client); + } + /** + * Query the granted bonuses Platform has not yet recorded as displayed. + * @param client - identity of the requesting UI; its language selects the server-authored message. + * @returns bonuses with their account, or null when the account grant is absent or changed. + */ + @Remote + getUnnotifiedBonuses(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.getUnnotifiedBonuses(client); + } + /** + * Record one displayed bonus as notified for the account it belongs to. + * @param accountId - account the notification was read for. + * @param orderId - granted bonus order the user saw. + * @param client - identity of the requesting UI; the Host derives Platform request headers from it. + * @returns true once Platform records the acknowledgement; false when the account is absent or changed. + */ + @Remote + ackBonusNotified( + accountId: AccountUserId, + orderId: AccountBonusOrderId, + client: AccountClientMetadata, + ): Promise { + return this.ctx.deepseekAccount.ackBonusNotified(accountId, orderId, client); + } + /** + * Begin browser sign-in. + * @param client - identity of the requesting UI, captured by a new attempt. + * @param callbackOrigin - browser-accessible loopback HTTP origin. + * @param loginSource - initiating UI, used to return from a failed exchange. + * @returns a new or already-active login attempt. + */ + @Remote + startSignIn( + client: AccountClientMetadata, + callbackOrigin: string, + loginSource: "web" | "desktop", + ): Promise { + return this.ctx.deepseekAccount.startSignIn(client, callbackOrigin, loginSource); + } + /** + * Cancel the named local attempt. + * @param attemptId - attempt to cancel. + * @returns settled cancellation or commit state. + */ + @Remote + cancelSignIn(attemptId: SignInAttemptId): Promise { + return this.ctx.deepseekAccount.cancelSignIn(attemptId); + } + /** + * Inspect the latest logged request providers of running tasks, including tools and retries. + * @returns whether running work has a latest request context on the account route. + */ + @Remote + hasRunningAccountTasks(): boolean { + return this.ctx.agents.list().some(isRunningAccountTask); + } + /** + * Remove the local account grant and revoke it through Platform in the background, without deleting API keys. + * @param client - identity of the requesting UI, captured for the background revocation retries. + * @returns state after removing the local account grant. + */ + @Remote + signOut(client: AccountClientMetadata): Promise { + return this.ctx.deepseekAccount.signOut(client); + } + /** + * Subscribe to credential expiry without replaying prior notifications. + * @param signal - stream lifetime. + * @returns notifications emitted while subscribed. + */ + @Remote({ mode: "stream" }) + async *watchExpiry(signal: AbortSignal): AsyncIterable<"session-expired"> { + let pending = 0; + let wake: (() => void) | undefined; + const stop = this.ctx.on("deepseek-account/session-expired", () => { + pending++; + wake?.(); + }); + const abort = (): void => { + wake?.(); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (!signal.aborted) { + if (pending > 0) { + pending--; + yield "session-expired"; + continue; + } + await new Promise((resolve) => { + wake = resolve; + }); + } + } finally { + stop(); + signal.removeEventListener("abort", abort); + } + } + /** + * Stream the safe account projection. + * @param signal - stream lifetime. + * @returns initial snapshot and subsequent changes. + */ + @Remote({ mode: "stream" }) + watch(signal: AbortSignal): AsyncIterable { + return this.ctx.deepseekAccount.watch(signal); + } +} +export default AccountController; diff --git a/apps/web-ui/packages/api/account-controller/src/types.ts b/apps/web-ui/packages/api/account-controller/src/types.ts new file mode 100644 index 000000000..dc358c025 --- /dev/null +++ b/apps/web-ui/packages/api/account-controller/src/types.ts @@ -0,0 +1,12 @@ +/** Account Remote values contain no credential payloads. */ +export type { + AccountBonusBatch, + AccountBonusNotification, + AccountBonusOrderId, + AccountClientMetadata, + AccountDetails, + AccountProfile, + AccountUserId, + AccountView, + SignInAttemptId, +} from "@deepseek-ai/dsh-deepseek-account/types"; diff --git a/apps/web-ui/packages/api/gateway/package.json b/apps/web-ui/packages/api/gateway/package.json new file mode 100644 index 000000000..c86e703d5 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-api-gateway", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./stream-protocol": { + "types": "./src/stream-protocol.ts", + "default": "./src/stream-protocol.ts" + } + }, + "dependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-typert-protocol": "*", + "@deepseek-ai/dsh-deque": "*", + "@deepseek-ai/dsh-util-crypto": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/index.ts b/apps/web-ui/packages/api/gateway/src/client/index.ts new file mode 100644 index 000000000..d98bf0b68 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/index.ts @@ -0,0 +1,969 @@ +/** + * Client projection of generated Typert Remote descriptors. Contributions + * install traced `remote.` services; no JavaScript Proxy + * participates in method lookup, invocation, or type exposure. + */ + +import { Service } from "@deepseek-ai/cordis"; +import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +export type { TypertGatewayFaultDetails } from "../remote-error-codes.ts"; +import type { Context } from "@deepseek-ai/cordis"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import type { + InvocationDescriptor, + TypertClientEventListener, + TypertClientRemote, + RemoteFailure, + RemoteResult, + RemoteStreamHandle, + TypertCodec, + TypertDisposer, + TypertRemoteContribution, + TypertRemoteEvent, +} from "@deepseek-ai/dsh-typert-protocol"; +import { + ClientUplinkQueue, + RemoteStreamCarrierError, + RemoteStreamMuxClient, +} from "./stream-client.ts"; +import { ClientRemoteEvents } from "./remote-events.ts"; +import { RemoteStream, type RemoteStreamOptions } from "./remote-stream.ts"; + +export { RemoteStreamCarrierError } from "./stream-client.ts"; +export { RemoteJournalStream } from "./journal-stream.ts"; +export type { + RemoteJournalChange, + RemoteJournalFrame, + RemoteJournalStreamOptions, + RemoteStreamFactory, +} from "./journal-stream.ts"; +export { RemoteStream } from "./remote-stream.ts"; +export type { RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts"; +export { RemoteSnapshotStream } from "./snapshot-stream.ts"; +export type { RemoteSnapshotStreamOptions } from "./snapshot-stream.ts"; + +interface MountToken { + active: boolean; + readonly abort: AbortController; +} + +interface ScopedProjection { + readonly context: string; + readonly wire: string; + readonly parameterIndex?: number; +} + +interface DirectMethod { + readonly descriptor: InvocationDescriptor; + readonly token: MountToken; +} + +interface ScopedMethod extends DirectMethod { + readonly projection: ScopedProjection; +} + +interface RemoteMethodRecord { + direct?: DirectMethod; + scoped?: ScopedMethod; +} + +interface BoundContextIdentity { + readonly value: unknown; +} + +interface PreparedClientInvocation { + readonly endpoint: string; + readonly args: Readonly>; + readonly signal: AbortSignal; +} + +interface RemoteNamespaceHandle { + readonly service: RemoteNamespaceService; + readonly dispose: TypertDisposer; +} + +interface LoaderReadiness { + await(): Promise; +} + +/** One descriptor's mounted variants, for the group disposer to unwind. */ +interface InstalledMethod { + readonly descriptor: InvocationDescriptor; + readonly token: MountToken; + direct: boolean; + scoped: boolean; +} + +/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ +export interface ClientRemote extends TypertClientRemote { + /** + * Create one independently cancellable, reconnecting logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a single-consumer stream annotated with physical generation ids. + */ + $stream(options: RemoteStreamOptions): RemoteStream; + /** + * Fixed Host facts as plain reads: no store, no subscription, no generation + * counter. `home` stays undefined until the first ready frame and reflects + * the latest one afterwards. + */ + readonly $host: RemoteHostFacts; +} + +/** The fixed Host facts exposed on `ctx.remote.$host`. */ +export interface RemoteHostFacts { + /** Host home directory from the ready frame, undefined before it. */ + readonly home: string | undefined; + /** Whether the carrier connects to the local Host. */ + readonly isLoopback: boolean; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Generated Remote namespaces selected by the Client assembly. */ + remote: ClientRemote; + } +} + +/** Required Client services: the Typert registry and the existing Connection carrier. */ +export const inject = ["typert", "connection"]; + +/** + * Install the typed Client Remote service. + * @param ctx - Client Cordis root. + */ +export function apply(ctx: Context): void { + new ClientRemoteService(ctx); +} + +class ClientRemoteService extends Service implements ClientRemote { + private readonly ownerCtx: Context; + private readonly connection: ConnectionHandle; + private readonly namespaces = new Map(); + private hostFacts: RemoteHostFacts | undefined; + private readonly streams = new RemoteStreamMuxClient(); + private readonly events: ClientRemoteEvents; + private mutations = Promise.resolve(); + + constructor(ctx: Context) { + super(ctx, "remote"); + this.ownerCtx = ctx; + const connection = ctx.get("connection") as ConnectionHandle; + this.connection = connection; + this.events = new ClientRemoteEvents(ctx, connection, (endpoint, payload, signal) => + this.openRemoteStream(endpoint, payload, signal), + ); + if (connection.rpc.open === undefined) this.streams.start(); + let disposed = false; + let loop: ReturnType | undefined; + const start = (): void => { + if (disposed) return; + if (connection.rpc.open === undefined) this.streams.start(); + loop = connection.start({ + onConnected: () => { + this.ownerCtx.emit("connection/reset"); + }, + onReconnectRequested: () => { + if (connection.rpc.open === undefined) this.streams.reconnect(); + }, + }); + }; + const loader = ctx.get("loader") as LoaderReadiness | undefined; + if (loader === undefined) start(); + else void loader.await().then(start, () => {}); + ctx.effect( + () => async () => { + disposed = true; + loop?.stop(); + await this.events.dispose(); + await this.streams.close(); + }, + "api-gateway.client.transport", + ); + } + + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(this.connection, options); + } + + get $host(): RemoteHostFacts { + // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs) + // compare by reference, so a fresh object is minted only when the fact + // itself changed. isLoopback is fixed for the page lifetime. + const home = this.connection.generation.getSnapshot()?.host.home; + if (this.hostFacts === undefined || this.hostFacts.home !== home) { + this.hostFacts = { home, isLoopback: this.connection.isLoopback }; + } + return this.hostFacts; + } + + async $mount(contribution: TypertRemoteContribution): ReturnType { + const callerCtx = this.ctx; + const owned = callerCtx.effect( + async () => { + const dispose = await this.enqueue(() => this.mountContribution(callerCtx, contribution)); + return () => this.enqueue(dispose); + }, + `api-gateway.client.$mount(${JSON.stringify(contribution.package)})`, + ); + await owned; + return async () => { + await owned(); + }; + } + + $on( + event: Event, + listener: TypertClientEventListener, + ): () => void { + return this.events.subscribe(this.ctx, event, listener); + } + + /** Open one Remote stream and normalize a worker-local carrier's structural failures. */ + private openRemoteStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + noConnection = `client api: ${endpoint} has no active Connection`, + ): AsyncIterable { + const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined; + if (connection === undefined) throw new Error(noConnection); + const local = connection.rpc.open?.("/api", endpoint, payload, signal, uplink); + return local === undefined + ? this.streams.open(endpoint, payload, signal, uplink) + : normalizeConnectionStream(local); + } + + private enqueue(operation: () => T | Promise): Promise { + const result = this.mutations.then(operation, operation); + this.mutations = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async mountContribution( + callerCtx: Context, + contribution: TypertRemoteContribution, + ): Promise { + this.validateContribution(contribution); + const disposeRemote = callerCtx.typert.remotes.register(contribution); + const groups = new Map(); + for (const descriptor of contribution.descriptors) { + const group = groups.get(descriptor.namespace); + if (group === undefined) groups.set(descriptor.namespace, [descriptor]); + else group.push(descriptor); + } + const installed: TypertDisposer[] = []; + try { + for (const [namespace, descriptors] of groups) { + installed.push(await this.installNamespace(namespace, descriptors)); + } + } catch (error) { + for (const dispose of installed.reverse()) await dispose(); + await disposeRemote(); + throw error; + } + return async () => { + for (const dispose of installed.reverse()) await dispose(); + await disposeRemote(); + }; + } + + private validateContribution(contribution: TypertRemoteContribution): void { + const direct = new Map>(); + const scoped = new Map>(); + const add = ( + table: Map>, + descriptor: InvocationDescriptor, + kind: "direct" | "scoped", + ): void => { + const methods = table.get(descriptor.namespace) ?? new Set(); + if (methods.has(descriptor.method)) { + throw new Error( + `client api: contribution repeats ${kind} method ${endpointOf(descriptor)}`, + ); + } + methods.add(descriptor.method); + table.set(descriptor.namespace, methods); + const namespace = this.namespaces.get(descriptor.namespace)?.service; + if (namespace?.has(kind, descriptor.method) === true) { + throw new Error(`client api: ${kind} method ${endpointOf(descriptor)} is already mounted`); + } + }; + for (const descriptor of contribution.descriptors) { + requireStrictInputs(descriptor); + if (descriptor.invocation.kind === "direct") add(direct, descriptor, "direct"); + if (scopedProjection(descriptor) !== undefined) add(scoped, descriptor, "scoped"); + } + const namespaces = new Set([...direct.keys(), ...scoped.keys()]); + for (const namespace of namespaces) { + const service = this.namespaces.get(namespace)?.service; + if (service === undefined) { + if (namespace in this) { + throw new Error( + `client api: namespace ${JSON.stringify(namespace)} conflicts with the Remote service`, + ); + } + const serviceKey = remoteServiceKey(namespace); + const property = this.ownerCtx.reflect.props[serviceKey]; + if (property?.type === "accessor" || this.ownerCtx.get(serviceKey) !== undefined) { + throw new Error( + `client api: namespace ${JSON.stringify(namespace)} conflicts with an existing Remote namespace`, + ); + } + } + for (const method of new Set([ + ...(direct.get(namespace) ?? []), + ...(scoped.get(namespace) ?? []), + ])) { + if (service === undefined) RemoteNamespaceService.assertMethodAvailable(namespace, method); + else service.assertMethodAvailable(method); + } + } + } + + /** + * Mount one namespace's descriptor group with no visibility gap: a fresh + * namespace installs its whole group synchronously inside its fiber's + * apply, so a plugin parked on the namespace service never observes it + * without the methods the same contribution carries; an existing namespace + * takes the group in one synchronous step. + * @param name - Remote namespace. + * @param descriptors - Every contribution descriptor naming that namespace. + * @returns disposer unmounting the group and the namespace once empty. + */ + private async installNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], + ): Promise { + let namespace = this.namespaces.get(name); + let installed: InstalledMethod[]; + if (namespace === undefined) { + ({ namespace, installed } = await this.createNamespace(name, descriptors)); + } else { + installed = installMethods(namespace.service, descriptors); + } + const handle = namespace; + return async () => { + for (const method of [...installed].reverse()) { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ + if (!method.token.active) continue; + method.token.active = false; + method.token.abort.abort(); + if (method.scoped) handle.service.remove("scoped", method.descriptor.method, method.token); + if (method.direct) handle.service.remove("direct", method.descriptor.method, method.token); + } + await this.disposeNamespace(name, handle); + }; + } + + private async createNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], + ): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> { + let service: RemoteNamespaceService | undefined; + let installed: InstalledMethod[] | undefined; + const fiber = this.ownerCtx.plugin({ + name: remoteServiceKey(name), + apply: (ctx: Context) => { + service = new RemoteNamespaceService(ctx, name, (direct, scoped, caller, args) => + this.invokeMethod(direct, scoped, caller, args), + ); + // Same synchronous window as the service registration: a dependent the + // new service unparks runs only after the methods exist. + installed = installMethods(service, descriptors); + }, + }); + try { + await fiber; + } catch (error) { + await fiber.dispose(); + throw error; + } + /* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */ + if (service === undefined || installed === undefined) { + throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`); + } + const namespace = { service, dispose: fiber.dispose }; + this.namespaces.set(name, namespace); + return { namespace, installed }; + } + + private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { + if (!namespace.service.empty || this.namespaces.get(name) !== namespace) return; + this.namespaces.delete(name); + await namespace.dispose(); + } + + private invokeMethod( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + values: readonly unknown[], + ): Promise> | RemoteStreamHandle { + if (scoped !== undefined) { + const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context); + const identity = adapter?.identity(callerCtx); + if (identity !== undefined) { + return this.invokeSelected( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + { value: identity }, + ); + } + } + if (direct !== undefined) { + return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values); + } + if (scoped !== undefined) { + return this.invokeSelected( + scoped.descriptor, + scoped.projection, + scoped.token, + callerCtx, + values, + ); + } + throw new Error("client api: Remote method is no longer mounted"); + } + + private invokeSelected( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> | RemoteStreamHandle { + if (descriptor.mode !== undefined) { + return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity); + } + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity); + } + + private async invoke( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> { + const endpoint = endpointOf(descriptor); + if (!token.active) return withdrawn(endpoint); + const prepared = this.prepareInvocation( + descriptor, + projection, + token, + callerCtx, + values, + boundIdentity, + ); + const connection = this.ownerCtx.get("connection") as ConnectionHandle | undefined; + if (connection === undefined) + throw new Error(`client api: ${endpoint} has no active Connection`); + try { + const result = await connection.rpc.call( + "/api", + endpoint, + { args: prepared.args }, + prepared.signal, + ); + if (!mountActive(token)) return withdrawn(endpoint); + prepared.signal.throwIfAborted(); + if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) }; + const value = + descriptor.result.mode === "strict" && descriptor.result.decode !== undefined + ? descriptor.result.decode(result.value) + : result.value; + return { ok: true, value }; + } catch (error) { + // Carrier throws (offline or abort) are outcomes of the call, not assembly + // faults, so they join the same error branch. A caller-aborted call is a + // cancellation even when the local throw wins the race against the wire + // round-trip, so it gets the same code the Host would have produced. + if (prepared.signal.aborted) return cancelledFailure(endpoint, error); + return carrierFailure(endpoint, error); + } + } + + /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */ + private invokeStream( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): RemoteStreamHandle { + const endpoint = endpointOf(descriptor); + if (!token.active) throw new Error(withdrawn(endpoint).error.message); + const prepared = this.prepareInvocation( + descriptor, + projection, + token, + callerCtx, + values, + boundIdentity, + ); + const generation = new AbortController(); + const uplink = new ClientUplinkQueue(endpoint); + const downlink = this.openRemoteStream( + endpoint, + { args: prepared.args }, + AbortSignal.any([prepared.signal, generation.signal]), + uplink, + ); + return new ClientStreamHandle(endpoint, downlink, uplink, generation, token); + } + + private prepareInvocation( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): PreparedClientInvocation { + const endpoint = endpointOf(descriptor); + const expected = + descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1); + const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1; + if (values.length !== expected && !hasCallerSignal) { + const contract = + descriptor.cancellation === undefined + ? `${String(expected)} argument(s)` + : `${String(expected)} business argument(s) plus an optional AbortSignal`; + throw new Error(`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`); + } + const args = Object.create(null) as Record; + if (projection !== undefined) { + const adapter = + boundIdentity === undefined + ? this.ownerCtx.typert.contexts.getClient(projection.context) + : undefined; + if (boundIdentity === undefined && adapter === undefined) { + throw new Error( + `client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`, + ); + } + const identity = + boundIdentity === undefined ? adapter?.identity(callerCtx) : boundIdentity.value; + if (identity === undefined) { + throw new Error( + `client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`, + ); + } + args[projection.wire] = identity; + } + let valueIndex = 0; + descriptor.parameters.forEach((parameter, parameterIndex) => { + if (parameterIndex === projection?.parameterIndex) return; + const value = values[valueIndex]; + if (value !== undefined) args[parameter.wire] = value; + valueIndex += 1; + }); + const callerSignal = hasCallerSignal + ? (values[expected] as AbortSignal | undefined) + : undefined; + const signal = + callerSignal === undefined + ? token.abort.signal + : AbortSignal.any([token.abort.signal, callerSignal]); + return { endpoint, args, signal }; + } +} + +/** + * The handle a generated stream method returns: one generation of one logical + * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the + * carrier pump drains; `dispose` aborts the generation and returns the carrier + * iterator, which sends `cancel` unless a terminal frame arrived, drops what was + * buffered, and ends the iteration quietly. + */ +class ClientStreamHandle implements RemoteStreamHandle { + private readonly downlink: AsyncIterator; + private primed: Promise> | undefined; + private consumed = false; + private disposed = false; + + constructor( + private readonly endpoint: string, + downlink: AsyncIterable, + private readonly uplink: ClientUplinkQueue, + private readonly generation: AbortController, + private readonly token: MountToken, + ) { + this.downlink = downlink[Symbol.asyncIterator](); + // The carrier opens the logical stream on the first pull; pulling now puts + // the `open` frame on the wire before any `send()`. The first read is kept + // for the consumer, and a failure waits for it instead of surfacing here; + // it also terminates the stream, so the queue closes and `send()` throws. + this.primed = this.downlink.next(); + void this.primed.catch(() => { + this.uplink.close(); + }); + } + + send(item: unknown): void { + if (!isRemoteUplinkItem(item)) + throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`); + this.uplink.push(item); + } + + end(): void { + this.uplink.end(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.uplink.close(); + this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)); + // Returning the carrier iterator reaches its `finally` even while nobody + // reads the downlink, so `cancel` goes out now and the pump stops. + void Promise.resolve(this.downlink.return?.()).catch(() => undefined); + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`); + this.consumed = true; + const iteration = this.iterate(); + return { + next: () => iteration.next(), + // A return before the first read ends a generator that never started, so its `finally` would not dispose. + return: (value?: unknown) => { + this.dispose(); + return iteration.return(value); + }, + }; + } + + private async *iterate(): AsyncGenerator { + try { + while (true) { + const next = await (this.primed ?? this.downlink.next()); + this.primed = undefined; + if (this.disposed || next.done === true) return; + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message); + yield next.value; + } + } catch (error) { + if (this.disposed) return; + throw error; + } finally { + // Ending early is a dispose; a terminated stream accepts no more uplink. + this.dispose(); + await this.downlink.return?.(); + } + } +} + +type InvokeRemote = ( + direct: DirectMethod | undefined, + scoped: ScopedMethod | undefined, + callerCtx: Context, + args: readonly unknown[], +) => Promise> | RemoteStreamHandle; + +class RemoteNamespaceService extends Service { + private readonly methods = new Map(); + private readonly namespace: string; + + static assertMethodAvailable(namespace: string, method: string): void { + if (REMOTE_NAMESPACE_FIELDS.has(method) || method in RemoteNamespaceService.prototype) { + throw new Error( + `client api: method ${JSON.stringify(`${namespace}/${method}`)} conflicts with its namespace service`, + ); + } + } + + constructor( + ctx: Context, + name: string, + private readonly invokeRemote: InvokeRemote, + ) { + super(ctx, remoteServiceKey(name)); + this.namespace = name; + } + + assertMethodAvailable(method: string): void { + RemoteNamespaceService.assertMethodAvailable(this.namespace, method); + if (method in this && !this.methods.has(method)) { + throw new Error( + `client api: method ${JSON.stringify(`${this.namespace}/${method}`)} conflicts with its namespace service`, + ); + } + } + + get empty(): boolean { + return this.methods.size === 0; + } + + has(kind: "direct" | "scoped", method: string): boolean { + return this.methods.get(method)?.[kind] !== undefined; + } + + installDirect(descriptor: InvocationDescriptor, token: MountToken): void { + this.install(descriptor.method, "direct", { descriptor, token }); + } + + installScoped( + descriptor: InvocationDescriptor, + projection: ScopedProjection, + token: MountToken, + ): void { + this.install(descriptor.method, "scoped", { descriptor, projection, token }); + } + + private install(method: string, kind: "direct", value: DirectMethod): void; + private install(method: string, kind: "scoped", value: ScopedMethod): void; + private install( + method: string, + kind: "direct" | "scoped", + value: DirectMethod | ScopedMethod, + ): void { + this.assertMethodAvailable(method); + let record = this.methods.get(method); + const fresh = record === undefined; + record ??= {}; + if (fresh) { + Object.defineProperty(this, method, { + configurable: true, + enumerable: true, + get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown { + const callerCtx = this.ctx; + const current = this.methods.get(method); + const direct = current?.direct; + const scoped = current?.scoped; + return (...args: unknown[]) => { + return this.invokeRemote(direct, scoped, callerCtx, args); + }; + }, + }); + this.methods.set(method, record); + } + if (kind === "direct") record.direct = value; + else record.scoped = value as ScopedMethod; + } + + remove(kind: "direct" | "scoped", method: string, token: MountToken): void { + const record = this.methods.get(method); + const current = record?.[kind]; + /* v8 ignore next -- duplicate live variants are rejected before installation, so no newer token can replace this one. */ + if (record === undefined || current?.token !== token) return; + if (kind === "direct") delete record.direct; + else delete record.scoped; + if (record.direct !== undefined || record.scoped !== undefined) return; + this.methods.delete(method); + Reflect.deleteProperty(this, method); + } +} + +/** + * Install one descriptor group on a namespace service, unwinding the partial + * group when a descriptor is refused. + * @param service - Namespace service taking the methods. + * @param descriptors - Descriptor group of one contribution. + * @returns per-descriptor records for the group disposer. + */ +function installMethods( + service: RemoteNamespaceService, + descriptors: readonly InvocationDescriptor[], +): InstalledMethod[] { + const installed: InstalledMethod[] = []; + try { + for (const descriptor of descriptors) { + const method: InstalledMethod = { + descriptor, + token: { active: true, abort: new AbortController() }, + direct: false, + scoped: false, + }; + installed.push(method); + if (descriptor.invocation.kind === "direct") { + service.installDirect(descriptor, method.token); + method.direct = true; + } + const projection = scopedProjection(descriptor); + if (projection !== undefined) { + service.installScoped(descriptor, projection, method.token); + method.scoped = true; + } + } + } catch (error) { + for (const method of [...installed].reverse()) { + method.token.active = false; + method.token.abort.abort(); + if (method.scoped) service.remove("scoped", method.descriptor.method, method.token); + if (method.direct) service.remove("direct", method.descriptor.method, method.token); + } + throw error; + } + return installed; +} + +const REMOTE_NAMESPACE_FIELDS = new Set([ + "ctx", + "empty", + "invokeRemote", + "methods", + "name", + "namespace", +]); + +function remoteServiceKey(namespace: string): string { + return `remote.${namespace}`; +} + +function endpointOf(descriptor: Pick): string { + return `${descriptor.namespace}/${descriptor.method}`; +} + +function mountActive(token: MountToken): boolean { + return token.active; +} + +function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | undefined { + if (descriptor.invocation.kind === "context") { + return { + context: descriptor.invocation.context, + wire: descriptor.invocation.wire, + }; + } + if (descriptor.scope === undefined) return undefined; + const lookupParameters = descriptor.parameters + .map((parameter, index) => ({ parameter, index })) + .filter((candidate) => candidate.parameter.source === "lookup"); + const selected = lookupParameters.length === 1 ? lookupParameters[0] : undefined; + if ( + selected === undefined || + selected.parameter.wire !== descriptor.scope.wire || + selected.parameter.lookup !== descriptor.scope.context + ) { + throw new Error( + `client api: generated Remote ${endpointOf(descriptor)} scope must select its only lookup parameter`, + ); + } + return { + context: descriptor.scope.context, + wire: descriptor.scope.wire, + parameterIndex: selected.index, + }; +} + +function requireStrictInputs(descriptor: InvocationDescriptor): void { + const endpoint = endpointOf(descriptor); + for (const parameter of descriptor.parameters) { + requireStrictCodec(parameter.codec, endpoint, parameter.wire); + } + if (descriptor.uplink !== undefined) + requireStrictCodec(descriptor.uplink.codec, endpoint, "uplink"); + if (descriptor.invocation.kind === "context") { + requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire); + } +} + +function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string): void { + if (codec.mode !== "strict") { + throw new Error( + `client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`, + ); + } +} + +/** The namespace retired before or during the call, so no request outcome exists. */ +function withdrawn(endpoint: string): Extract, { readonly ok: false }> { + return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`); +} + +/** + * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and + * the thrown message. Exported so a stand-in for this face folds identically. + * @param endpoint - `/` that was called. + * @param error - what the carrier threw. + * @returns the failed result. + */ +export function carrierFailure( + endpoint: string, + error: unknown, +): Extract, { readonly ok: false }> { + return internalFailure( + `client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`, + ); +} + +/** + * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`. + * @param endpoint - `/` that was called. + * @param cause - what the carrier threw when the signal aborted. + * @returns the failed result. + */ +export function cancelledFailure( + endpoint: string, + cause: unknown, +): Extract, { readonly ok: false }> { + return { + ok: false, + error: new RemoteError( + "gateway/cancelled", + `client api: Remote invocation "${endpoint}" was aborted`, + {}, + { cause }, + ), + }; +} + +function internalFailure(message: string): Extract, { readonly ok: false }> { + return { ok: false, error: new RemoteError("gateway/internal", message, {}) }; +} + +/** + * Whether a caught value is a Remote failure this face delivered or threw. + * The one consumer-facing discrimination point: marked instances carry their + * Host code; anything else is a local fault the caller should let crash. + * @param error - a caught value. + * @returns true when the value narrows to RemoteFailure. + */ +export function isRemoteFailure(error: unknown): error is RemoteFailure { + return remoteErrorOf(error) !== undefined; +} + +/** + * Rebuild the wire failure as a local RemoteError instance so the error branch + * carries a real Error and `throw result.error` keeps throw semantics. The code + * is passed through verbatim without runtime validation: a code outside this + * Client's merged map still surfaces as-is, so a newer Host stays readable. + */ +function rebuiltFailure(error: { code: string; message: string; details: object }): RemoteFailure { + return new RemoteError(error.code as never, error.message, error.details as never); +} + +type MarkedConnectionStreamFailure = Error & { + readonly dshRemoteStreamFailure?: + | { readonly kind: "remote"; readonly code: string; readonly details: object } + | { readonly kind: "carrier" }; +}; + +/** Preserve Gateway error classes across a worker transport's separately bundled page half. */ +async function* normalizeConnectionStream(source: AsyncIterable): AsyncGenerator { + try { + yield* source; + } catch (error) { + if (!(error instanceof Error)) throw error; + const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure; + if (marker?.kind === "remote") { + throw new RemoteError(marker.code as never, error.message, marker.details as never); + } + if (marker?.kind === "carrier") { + throw new RemoteStreamCarrierError(error.message, { cause: error }); + } + throw error; + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts new file mode 100644 index 000000000..952dfa33b --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/journal-stream.ts @@ -0,0 +1,604 @@ +/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { RemoteStreamCarrierError } from "./stream-client.ts"; +import type { RemoteStream, RemoteStreamItem, RemoteStreamOptions } from "./remote-stream.ts"; + +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<"gateway/internal"> { + return new RemoteError("gateway/internal", message, {}); +} + +/** Transport-neutral opening snapshot, durable entry, or cursorless notification. */ +export type RemoteJournalFrame = + | { readonly type: "opened"; readonly cursor: Cursor; readonly page: Page } + | { readonly type: "entry"; readonly entry: Entry } + | ([Notification] extends [never] + ? never + : { readonly type: "notification"; readonly notification: Notification }); + +/** One journal-window update or cursorless domain notification. */ +export type RemoteJournalChange = + | { + readonly type: "replace"; + readonly page: Page; + readonly entries: readonly Entry[]; + readonly hasMore: boolean; + } + | { + readonly type: "prepend"; + readonly page: Page; + readonly entries: readonly Entry[]; + readonly hasMore: boolean; + } + | { readonly type: "append"; readonly entry: Entry } + | ([Notification] extends [never] + ? never + : { readonly type: "notification"; readonly notification: Notification }); + +type JournalStreamItem = RemoteStreamItem< + RemoteJournalFrame +>; + +/** Gateway capability used to create one reconnecting Remote stream. */ +export interface RemoteStreamFactory { + /** + * Create one independently cancellable logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a reconnecting single-consumer stream. + */ + $stream(options: RemoteStreamOptions): RemoteStream; +} + +/** Domain publication and cursor operations for one addressed journal stream. */ +export interface RemoteJournalStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string; + /** Cursor representing a journal with no entries. */ + readonly emptyCursor: Cursor; + /** Read the ordered entries carried by a page. */ + readonly entries: (page: Page) => readonly Entry[]; + /** Read whether an older page exists. */ + readonly hasMore: (page: Page) => boolean; + /** Read the inclusive first durable cursor covered by one entry. */ + readonly first: (entry: Entry) => Cursor; + /** Read the inclusive final cursor, which must not precede the first. */ + readonly last: (entry: Entry) => Cursor; + /** Compare two cursors. */ + readonly compare: (left: Cursor, right: Cursor) => number; + /** Test whether the right cursor immediately follows the left cursor. */ + readonly follows: (left: Cursor, right: Cursor) => boolean; + /** Apply one complete journal-window change or cursorless notification. */ + readonly publish: (change: RemoteJournalChange) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void; +} + +/** + * Owns snapshot-first opening, ordered live delivery, pagination, and repair. + * + * The domain retains its published window during reconnection. A replacement is + * published only after the opening page reaches the generation's cursor. + * Notifications never change a cursor and wait behind an in-flight gap repair. + */ +export abstract class RemoteJournalStream< + Page, + Entry, + Cursor, + PageRequest = void, + Notification = never, +> { + private readonly stream: RemoteStream>; + private initialRequest!: PageRequest; + private resumeCursor: Cursor | undefined; + private hasResumeCursor = false; + private generation = 0; + private firstCursor: Cursor | undefined; + private lastCursor: Cursor | undefined; + private started = false; + private opened = false; + private disposed = false; + private done: Promise | undefined; + private closing: Promise | undefined; + private pendingNext: + Promise>> | undefined; + + /** + * @param remote - Gateway factory for the reconnecting physical-generation stream. + * @param options - cursor algebra and domain publication sinks. + */ + protected constructor( + remote: RemoteStreamFactory, + private readonly options: RemoteJournalStreamOptions, + ) { + this.stream = remote.$stream>({ + name: options.name, + open: (signal) => this.follow(this.initialRequest, signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : protocolViolation( + `${this.hasResumeCursor ? "resumed " : ""}${options.name} ended before its opening cursor`, + ), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + } + + /** + * Open one physical journal generation with a complete current snapshot. + * @param request - opening-window request retained for later repair. + * @param signal - cancellation lifetime of the physical generation. + * @returns opening cursor followed by live entries. + */ + protected abstract follow( + request: PageRequest, + signal: AbortSignal, + ): AsyncIterable>; + + /** + * Read one journal page through the addressed domain source. + * @param request - domain page request. + * @param through - inclusive journal cursor that fixes the source read. + * @param signal - cancellation lifetime shared with the logical stream. + * @returns the requested page, whose tail equals `through` unless the domain request selects older entries. + */ + protected abstract readPage( + request: PageRequest, + through: Cursor, + signal: AbortSignal, + ): Promise; + + /** + * Derive an unbounded-tail request from the initial page request. + * @param initial - request used to open the journal window. + * @returns request suitable for reconnect and gap repair. + */ + protected abstract repairRequest(initial: PageRequest): PageRequest; + + /** Cancellation lifetime shared by follow and page calls. */ + get signal(): AbortSignal { + return this.stream.signal; + } + + /** + * Establish follow and publish the opening snapshot carried by its first frame. + * @param request - initial tail-page request. + * @returns after the first complete window is published. + */ + async open(request: PageRequest): Promise { + if (this.started) throw new Error(`${this.options.name} already opened`); + this.started = true; + this.initialRequest = request; + const iterator = this.stream[Symbol.asyncIterator](); + try { + const first = await this.takeNext(iterator); + if (first.done) + throw protocolViolation(`${this.options.name} ended before its opening cursor`); + this.replaceGeneration(first.value, false); + this.opened = true; + this.done = this.consume(iterator); + } catch (error) { + await this.stream.dispose(); + throw error; + } + } + + /** + * Read and prepend one older page after a successful open. + * @param request - domain page request bound to this stream's address. + * @returns after the page is applied or rejected as discontinuous. + */ + async prepend(request: PageRequest): Promise { + if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`); + const page = await this.readPage(request, this.currentCursor(), this.stream.signal); + this.stream.signal.throwIfAborted(); + const entries = this.options.entries(page); + this.assertPage(entries); + const before = this.firstCursor; + const accepted = + before === undefined + ? [...entries] + : entries.filter((entry) => this.options.compare(this.options.first(entry), before) < 0); + const tail = accepted.at(-1); + if ( + tail !== undefined && + before !== undefined && + !this.options.follows(this.options.last(tail), before) + ) { + this.options.publish({ type: "prepend", page, entries: [], hasMore: false }); + throw protocolViolation(`${this.options.name} history page is discontinuous`); + } + const first = accepted[0]; + if (first !== undefined) this.firstCursor = this.options.first(first); + this.options.publish({ + type: "prepend", + page, + entries: accepted, + hasMore: this.options.hasMore(page), + }); + } + + /** Replace the active physical generation while retaining the published window. */ + restart(): void { + this.stream.restart(); + } + + /** + * Permanently stop follow, page requests, and the background consumer. + * @returns when no stream work or publication callback can still run. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing; + this.disposed = true; + const done = this.done; + const closing = (async () => { + await this.stream.dispose(); + await done; + })(); + this.closing = closing; + return closing; + } + + private async consume( + iterator: AsyncIterator>, + ): Promise { + try { + while (true) { + const next = await this.takeNext(iterator); + if (next.done) return; + const item = next.value; + if (item.generation !== this.generation) { + this.replaceGeneration(item, true); + continue; + } + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + if (item.value.type === "notification") { + this.publishNotification(item.value.notification); + continue; + } + await this.acceptEntry(item.value.entry, item, iterator); + } + } catch (error) { + if (!this.disposed) this.options.failed(error); + } + } + + private replaceGeneration( + initial: JournalStreamItem, + resumed: boolean, + ): void { + const opening = this.opening(initial, resumed); + this.replaceFromOpening(opening.page, opening.cursor); + } + + private opening( + item: RemoteStreamItem>, + resumed: boolean, + ): { readonly cursor: Cursor; readonly page: Page } { + if (item.value.type !== "opened") { + throw protocolViolation( + `${resumed ? "resumed " : ""}${this.options.name} emitted an entry before its opening cursor`, + ); + } + const cursor = item.value.cursor; + if ( + resumed && + this.lastCursor !== undefined && + this.options.compare(cursor, this.lastCursor) < 0 + ) { + throw protocolViolation( + `${this.options.name} resumed at a cursor behind the last applied entry`, + ); + } + this.generation = item.generation; + item.accept(); + return { cursor, page: item.value.page }; + } + + /** Publish a generation's opening page without issuing a second Remote call. */ + private replaceFromOpening(page: Page, cursor: Cursor): void { + this.assertPageThrough(page, cursor); + const entries = [...this.options.entries(page)]; + this.assertPage(entries); + const first = entries[0]; + this.firstCursor = first === undefined ? undefined : this.options.first(first); + this.lastCursor = cursor; + this.setResumeCursor(cursor); + this.options.publish({ + type: "replace", + page, + entries, + hasMore: this.options.hasMore(page), + }); + } + + private async acceptEntry( + entry: Entry, + item: JournalStreamItem, + iterator: AsyncIterator>, + ): Promise { + const { first, last: cursor } = this.entryRange(entry); + const last = this.lastCursor as Cursor; + if (this.options.compare(cursor, last) <= 0) return; + if (this.options.compare(first, last) <= 0) { + throw protocolViolation(`${this.options.name} emitted a partially overlapping entry`); + } + if (!this.options.follows(last, first)) { + const request = this.repairPageRequest(); + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [entry], + [], + ); + if (superseded !== undefined) { + this.replaceGeneration(superseded, true); + } + return; + } + if (this.firstCursor === undefined) this.firstCursor = first; + this.lastCursor = cursor; + this.setResumeCursor(cursor); + this.options.publish({ type: "append", entry }); + } + + private async replaceThrough( + request: PageRequest, + requiredCursor: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + notifications: Notification[], + ): Promise | undefined> { + let read = await this.readPageWhileFollowing( + request, + requiredCursor, + generation, + signal, + iterator, + queued, + notifications, + ); + if (read.type === "superseded") return read.item; + let page = read.page; + this.assertPageThrough(page, requiredCursor); + let entries = this.mergeReplacement(page, queued); + let target = this.maxCursor(requiredCursor, queued); + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + read = await this.readPageWhileFollowing( + this.repairPageRequest(), + target, + generation, + signal, + iterator, + queued, + notifications, + ); + if (read.type === "superseded") return read.item; + page = read.page; + this.assertPageThrough(page, target); + entries = this.mergeReplacement(page, queued); + target = this.maxCursor(requiredCursor, queued); + } + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + throw protocolViolation(`${this.options.name} page did not reach its opening cursor`); + } + const first = entries[0]; + /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */ + this.firstCursor = first === undefined ? undefined : this.options.first(first); + this.lastCursor = this.tailCursor(entries); + this.setResumeCursor(this.lastCursor); + this.options.publish({ + type: "replace", + page, + entries, + hasMore: this.options.hasMore(page), + }); + for (const notification of notifications) { + this.publishNotification(notification); + } + return undefined; + } + + private async readPageWhileFollowing( + request: PageRequest, + through: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + notifications: Notification[], + ): Promise< + | { readonly type: "page"; readonly page: Page } + | { + readonly type: "superseded"; + readonly item: JournalStreamItem; + } + > { + const page = this.readPage(request, through, signal).then( + (value) => ({ type: "page" as const, value }), + (error: unknown) => ({ type: "page-error" as const, error }), + ); + while (true) { + const pending = this.nextResult(iterator); + const next = pending.then( + (value) => ({ type: "next" as const, value }), + (error: unknown) => ({ type: "next-error" as const, error }), + ); + const result = await Promise.race([page, next]); + if (result.type === "page") { + signal.throwIfAborted(); + return { type: "page", page: result.value }; + } + if (result.type === "page-error") { + if (!signal.aborted || this.stream.signal.aborted) throw result.error; + return this.awaitReplacementGeneration(generation, iterator, pending); + } + this.releaseNext(); + if (result.type === "next-error") throw result.error; + if (result.value.done) { + signal.throwIfAborted(); + throw protocolViolation(`${this.options.name} ended while reading its replacement page`); + } + const item = result.value.value; + if (item.generation !== generation) return { type: "superseded", item }; + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + if (item.value.type === "notification") { + notifications.push(item.value.notification); + continue; + } + queued.push(item.value.entry); + } + } + + private async awaitReplacementGeneration( + generation: number, + iterator: AsyncIterator>, + initial: Promise>>, + ): Promise<{ + readonly type: "superseded"; + readonly item: JournalStreamItem; + }> { + let pending = initial; + while (true) { + let next: IteratorResult>; + try { + next = await pending; + } finally { + this.releaseNext(); + } + if (next.done) { + this.stream.signal.throwIfAborted(); + throw protocolViolation( + `${this.options.name} ended while replacing an aborted page generation`, + ); + } + const item = next.value; + if (item.generation !== generation) return { type: "superseded", item }; + if (item.value.type === "opened") { + throw protocolViolation(`${this.options.name} emitted more than one opening cursor`); + } + pending = this.nextResult(iterator); + } + } + + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { + const entries = [...this.options.entries(page)]; + this.assertPage(entries); + for (const entry of queued) this.entryRange(entry); + const sorted = [...queued].sort((left, right) => + this.options.compare(this.options.first(left), this.options.first(right)), + ); + let tail = this.tailCursor(entries); + for (const entry of sorted) { + const first = this.options.first(entry); + const last = this.options.last(entry); + if (this.options.compare(last, tail) <= 0) continue; + if (this.options.compare(first, tail) <= 0) { + throw protocolViolation( + `${this.options.name} replacement contains a partially overlapping entry`, + ); + } + if (!this.options.follows(tail, first)) return undefined; + entries.push(entry); + tail = last; + } + return entries; + } + + private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor { + let result = cursor; + for (const entry of entries) { + const candidate = this.options.last(entry); + if (this.options.compare(candidate, result) > 0) result = candidate; + } + return result; + } + + private nextResult( + iterator: AsyncIterator>, + ): Promise>> { + this.pendingNext ??= iterator.next(); + return this.pendingNext; + } + + private async takeNext( + iterator: AsyncIterator>, + ): Promise>> { + const pending = this.nextResult(iterator); + try { + return await pending; + } finally { + this.releaseNext(); + } + } + + private releaseNext(): void { + this.pendingNext = undefined; + } + + private publishNotification(notification: Notification): void { + this.options.publish({ + type: "notification", + notification, + } as RemoteJournalChange); + } + + private repairPageRequest(): PageRequest { + return this.repairRequest(this.initialRequest); + } + + private setResumeCursor(cursor: Cursor): void { + this.resumeCursor = cursor; + this.hasResumeCursor = true; + } + + private currentCursor(): Cursor { + return this.resumeCursor as Cursor; + } + + private tailCursor(entries: readonly Entry[]): Cursor { + const tail = entries.at(-1); + return tail === undefined ? this.options.emptyCursor : this.options.last(tail); + } + + private assertPage(entries: readonly Entry[]): void { + const iterator = entries[Symbol.iterator](); + const first = iterator.next(); + if (first.done) return; + let previousRange = this.entryRange(first.value); + for (const entry of iterator) { + const range = this.entryRange(entry); + if (!this.options.follows(previousRange.last, range.first)) { + throw protocolViolation(`${this.options.name} page contains discontinuous entries`); + } + previousRange = range; + } + } + + private entryRange(entry: Entry): { readonly first: Cursor; readonly last: Cursor } { + const first = this.options.first(entry); + const last = this.options.last(entry); + if (this.options.compare(first, last) > 0) { + throw protocolViolation(`${this.options.name} entry has an inverted cursor range`); + } + return { first, last }; + } + + private assertPageThrough(page: Page, through: Cursor): void { + const tail = this.tailCursor(this.options.entries(page)); + if (this.options.compare(tail, through) !== 0) { + throw protocolViolation(`${this.options.name} page did not end at its requested cursor`); + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-events.ts b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts new file mode 100644 index 000000000..7d6cc1d52 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/remote-events.ts @@ -0,0 +1,354 @@ +/** Client owner for forwarded Remote Event subscriptions and deliveries. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionHandle, +} from "@deepseek-ai/dsh-client-connection/client"; +import type { + TypertClientEventListener, + TypertOwnedValue, + TypertRemoteEvent, +} from "@deepseek-ai/dsh-typert-protocol"; +import { isRemoteJsonValue, isTypertOwnedValue } from "@deepseek-ai/dsh-typert-protocol"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import { + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + isRemoteEventAgentId, + isRemoteEventClientId, + isRemoteEventId, + projectRemoteEventRejection, + type RemoteEventClientId, + type RemoteEventDownlinkFrame, + type RemoteEventEmitFrame, + type RemoteEventInvocationFrame, + type RemoteEventResult, +} from "../stream-protocol.ts"; + +/** Open the Gateway-internal forwarded-event stream on the selected carrier. */ +export type RemoteEventStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable; + +/** One subscribed listener after its event-specific signature is erased. */ +type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown; + +/** Untyped access used only for instance-private Cordis event keys. */ +interface PrivateEventContext { + on(name: string, listener: RemoteEventListener): () => boolean; + parallel(name: string, ...args: unknown[]): Promise; + waterfall( + thisArg: Context, + name: string, + request: Readonly>, + next: () => Promise, + ): unknown; +} + +/** Transport outcome after one Client listener chain either claims or delegates. */ +type RemoteEventReplyOutcome = + | { readonly kind: "result"; readonly value: unknown } + | { readonly kind: "next" } + | { readonly kind: "rejected"; readonly error: ReturnType }; + +/** Private end-of-chain marker that cannot collide with a JSON listener result. */ +const REMOTE_EVENT_NEXT = Symbol("api-gateway.remote-event.next"); + +/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */ +export class ClientRemoteEvents { + private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/`; + private readonly unregisterGeneration: () => void; + private activeGeneration: Promise | undefined; + + /** + * @param ownerCtx - Client Gateway root used for Agent Context resolution. + * @param connection - Connection carrier used for HTTP result calls. + * @param openStream - selected in-process or WebSocket stream opener. + */ + constructor( + private readonly ownerCtx: Context, + private readonly connection: ConnectionHandle, + private readonly openStream: RemoteEventStreamOpener, + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration); + } + + /** + * Register one typed Remote Event listener in its calling fiber. + * @param callerCtx - fiber Context owning the registration. + * @param event - selected forwarded event. + * @param listener - listener derived from that event's declaration. + * @returns disposer for this exact registration. + */ + subscribe( + callerCtx: Context, + event: Event, + listener: TypertClientEventListener, + ): () => void { + const dispose = privateEvents(callerCtx).on( + this.eventKey(event), + listener as RemoteEventListener, + ); + return () => { + dispose(); + }; + } + + /** Withdraw the generation source and wait for active listener work to quiesce. */ + async dispose(): Promise { + this.unregisterGeneration(); + await Promise.allSettled([this.activeGeneration]); + } + + /** Track the current generation so plugin disposal waits for listener work to stop. */ + private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => { + const tracked = this.pumpEvents(signal, ready).finally(() => { + if (this.activeGeneration === tracked) this.activeGeneration = undefined; + }); + this.activeGeneration = tracked; + return tracked; + }; + + /** Deliver one notification through Cordis while containing listener failures. */ + private deliver(frame: RemoteEventEmitFrame): void { + void privateEvents(this.ownerCtx) + .parallel(this.eventKey(frame.event), ...frame.args) + .catch((error: unknown) => { + this.reportError(frame.event, error); + }); + } + + /** Run one Connection generation over the forwarded-event logical stream. */ + private async pumpEvents( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, + ): Promise { + let clientId: RemoteEventClientId | undefined; + const failed = new AbortController(); + const generationSignal = AbortSignal.any([signal, failed.signal]); + const active = new Map(); + const tasks = new Set>(); + const source = this.openStream( + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + generationSignal, + ); + let streamFailed = false; + let streamError: unknown; + try { + for await (const value of source) { + if (clientId === undefined) { + const opening = parseRemoteEventReady(value); + clientId = opening.clientId; + ready(opening.host); + continue; + } + const frame = parseRemoteEventFrame(value); + if (frame.type === "cancel") { + active + .get(frame.eventId) + ?.abort(new Error("client api: Remote event was cancelled by the Host")); + continue; + } + if (frame.type === "emit") { + this.deliver(frame); + continue; + } + const controller = new AbortController(); + active.set(frame.eventId, controller); + const deliverySignal = AbortSignal.any([generationSignal, controller.signal]); + const task = this.answer(frame, clientId, deliverySignal) + .catch((error: unknown) => { + if (!deliverySignal.aborted) failed.abort(error); + }) + .finally(() => { + active.delete(frame.eventId); + tasks.delete(task); + }); + tasks.add(task); + } + } catch (error) { + streamFailed = true; + streamError = error; + } finally { + for (const controller of active.values()) { + controller.abort(new Error("client api: Remote event generation ended")); + } + await Promise.allSettled(tasks); + } + if (failed.signal.aborted) { + throw toError(failed.signal.reason, "client api: Remote event result delivery failed"); + } + if (signal.aborted) return; + if (streamFailed) throw streamError; + throw new Error("client api: forwarded Remote event stream ended unexpectedly"); + } + + private async answer( + frame: RemoteEventInvocationFrame, + clientId: RemoteEventClientId, + signal: AbortSignal, + ): Promise { + const adapter = this.ownerCtx.typert.contexts.getClient("agent"); + let resolved: Context | TypertOwnedValue | undefined; + try { + resolved = adapter?.resolve(frame.agentId); + } catch (error) { + this.reportError(frame.event, error); + } + const owned = isTypertOwnedValue(resolved) ? resolved : undefined; + try { + const target = isTypertOwnedValue(resolved) ? resolved.value : resolved; + let outcome: RemoteEventReplyOutcome = { kind: "next" }; + if (target !== undefined) { + try { + outcome = await this.dispatchWaterfall(target, frame, signal); + } catch (error) { + if (signal.aborted) return; + outcome = { kind: "rejected", error: projectRemoteEventRejection(error) }; + } + } + if (signal.aborted) return; + const result: RemoteEventResult = { + clientId, + eventId: frame.eventId, + outcome: + outcome.kind === "result" && outcome.value === undefined ? { kind: "result" } : outcome, + }; + const response = await this.connection.rpc.call( + "/api", + REMOTE_EVENT_RESULT_ENDPOINT, + { args: result }, + signal, + ); + if (!response.ok) throw new Error(response.error.message); + } finally { + owned?.[Symbol.dispose](); + } + } + + private async dispatchWaterfall( + target: Context, + frame: RemoteEventInvocationFrame, + signal: AbortSignal, + ): Promise { + const request = { + ...frame.request, + agent: target, + signal, + }; + // Cancellation reaches the handler, whose Context remains owned until it settles. + const value = await privateEvents(target).waterfall( + target, + this.eventKey(frame.event), + request, + () => Promise.resolve(REMOTE_EVENT_NEXT), + ); + if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) { + throw new TypeError("Remote event listener result is not lossless JSON data"); + } + return value === REMOTE_EVENT_NEXT ? { kind: "next" } : { kind: "result", value }; + } + + private eventKey(event: string): string { + return `${this.eventPrefix}${event}`; + } + + private reportError(event: string, error: unknown): void { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error); + } +} + +/** Validate and return one generation's Client identity and Host facts. */ +function parseRemoteEventReady(value: unknown): { + readonly clientId: RemoteEventClientId; + readonly host: ConnectionHostInfo; +} { + if ( + !isRemoteEventRecord(value) || + !hasExactRemoteEventKeys(value, ["type", "clientId", "host"]) || + value.type !== "ready" || + !isRemoteEventClientId(value.clientId) || + !isRemoteEventRecord(value.host) || + !hasExactRemoteEventKeys(value.host, ["home"]) || + typeof value.host.home !== "string" + ) { + throw new TypeError("client api: forwarded Remote event stream did not begin with ready"); + } + return { clientId: value.clientId, host: { home: value.host.home } }; +} + +/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ +function parseRemoteEventFrame( + value: unknown, +): Exclude { + if (!isRemoteEventRecord(value)) invalidRemoteEventFrame(); + if ( + value.type === "cancel" && + hasExactRemoteEventKeys(value, ["type", "eventId"]) && + isRemoteEventId(value.eventId) + ) { + return { type: "cancel", eventId: value.eventId }; + } + if ( + value.type === "emit" && + hasExactRemoteEventKeys(value, ["type", "event", "args"]) && + validRemoteEventName(value.event) && + Array.isArray(value.args) && + isRemoteJsonValue(value.args) + ) { + return { type: "emit", event: value.event, args: value.args }; + } + if ( + value.type === "waterfall" && + hasExactRemoteEventKeys(value, ["type", "event", "eventId", "agentId", "request"]) && + validRemoteEventName(value.event) && + isRemoteEventId(value.eventId) && + isRemoteEventAgentId(value.agentId) && + isRemoteEventRecord(value.request) && + !Object.hasOwn(value.request, "agent") && + !Object.hasOwn(value.request, "signal") && + isRemoteJsonValue(value.request) + ) { + return { + type: "waterfall", + event: value.event, + eventId: value.eventId, + agentId: value.agentId, + request: value.request, + }; + } + invalidRemoteEventFrame(); +} + +function isRemoteEventRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value); + return ownKeys.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} + +function validRemoteEventName(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function invalidRemoteEventFrame(): never { + throw new TypeError("client api: invalid forwarded Remote event frame"); +} + +function privateEvents(ctx: Context): PrivateEventContext { + return ctx; +} + +function toError(reason: unknown, message: string): Error { + return reason instanceof Error ? reason : new Error(message, { cause: reason }); +} diff --git a/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts new file mode 100644 index 000000000..85291e321 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/remote-stream.ts @@ -0,0 +1,230 @@ +/** Reconnecting lifecycle for one single-consumer Remote stream. */ + +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import { RemoteStreamCarrierError } from "./stream-client.ts"; + +/** One item annotated with the physical Remote-stream generation that delivered it. */ +export interface RemoteStreamItem { + /** Monotone physical generation number within this logical stream. */ + readonly generation: number; + /** Decoded item yielded by the generated Remote method. */ + readonly value: Item; + /** Cancellation lifetime of the generation that delivered this item. */ + readonly signal: AbortSignal; + /** Mark this generation's opening baseline or cursor as accepted. */ + accept(): void; +} + +/** Domain-owned operations used by {@link RemoteStream}. */ +export interface RemoteStreamOptions { + /** Diagnostic owner name used for cancellation failures. */ + readonly name: string; + /** Open one physical generation of the logical stream. */ + readonly open: (signal: AbortSignal) => AsyncIterable; + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error; + /** Observe a retryable carrier loss before the supervisor waits or reopens. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; +} + +/** + * Reopens one logical Remote stream across carrier generations. + * + * Connection owns physical retry timing; Gateway performs each requested + * replacement. The domain consumer owns its opening item and every later + * item, and calls {@link RemoteStreamItem.accept} only after validating the + * opening baseline or cursor. + */ +export class RemoteStream implements AsyncIterable> { + private readonly lifetime = new AbortController(); + private generationAbort: AbortController | undefined; + private iterator: AsyncGenerator> | undefined; + private closing: Promise | undefined; + private revision = 0; + private taken = false; + + /** + * @param connection - observable Host generation source used to pace retries. + * @param options - domain stream opener, end classification, and diagnostics. + */ + constructor( + private readonly connection: Pick, + private readonly options: RemoteStreamOptions, + ) {} + + /** Cancellation lifetime shared by the stream and sibling page requests. */ + get signal(): AbortSignal { + return this.lifetime.signal; + } + + /** Interrupt the current generation and immediately request a replacement. */ + restart(): void { + if (this.lifetime.signal.aborted) return; + this.revision++; + this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`)); + } + + /** + * Permanently stop this stream and wait for its iterator to close. + * @returns when the active generation and consumer iterator are quiescent. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing; + if (!this.lifetime.signal.aborted) { + const reason = new Error(`${this.options.name} disposed`); + this.lifetime.abort(reason); + this.generationAbort?.abort(reason); + } + const iterator = this.iterator; + if (iterator === undefined) return Promise.resolve(); + const closing = closeRemoteStreamIterator(iterator); + this.closing = closing; + return closing; + } + + /** @inheritdoc */ + [Symbol.asyncIterator](): AsyncIterator> { + if (this.taken) throw new Error(`${this.options.name} already has a consumer`); + this.taken = true; + const iterator = this.read(); + this.iterator = iterator; + return iterator; + } + + private async *read(): AsyncGenerator> { + let attempt = 0; + let generation = 0; + let observedRevision = this.revision; + try { + while (!isAborted(this.lifetime.signal)) { + if (observedRevision !== this.revision) { + observedRevision = this.revision; + attempt = 0; + } + const revision = this.revision; + const generationAbort = new AbortController(); + this.generationAbort = generationAbort; + const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]); + const generationId = ++generation; + let accepted = false; + try { + for await (const value of this.options.open(signal)) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) break; + yield { + generation: generationId, + value, + signal, + accept: () => { + if (this.generationAbort !== generationAbort || revision !== this.revision) return; + accepted = true; + attempt = 0; + }, + }; + } + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + throw this.options.ended(accepted); + } catch (error) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error); + this.options.carrierFailed?.(error); + if (revision !== this.revision) continue; + attempt++; + try { + await waitForRemoteStreamRetry(this.connection, error, attempt, signal); + } catch (retryError) { + if (isAborted(this.lifetime.signal)) return; + if (revision !== this.revision) continue; + throw terminalStreamFailure(retryError); + } + } finally { + this.generationAbort = undefined; + if (!generationAbort.signal.aborted) { + generationAbort.abort(new Error(`${this.options.name} generation ended`)); + } + } + } + } finally { + if (!this.lifetime.signal.aborted) { + this.lifetime.abort(new Error(`${this.options.name} consumer closed`)); + } + this.generationAbort?.abort(this.lifetime.signal.reason); + this.generationAbort = undefined; + } + } +} + +async function waitForRemoteStreamRetry( + connection: Pick, + error: RemoteStreamCarrierError, + attempt: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + if (connection.generation.getSnapshot() !== undefined) { + if (attempt === 1) return; + throw error; + } + await new Promise((resolve, reject) => { + const subscription: { + dispose?: () => void; + finished: boolean; + } = { finished: false }; + const finish = (failure?: Error): void => { + if (subscription.finished) return; + subscription.finished = true; + subscription.dispose?.(); + signal.removeEventListener("abort", aborted); + if (failure === undefined) resolve(); + else reject(failure); + }; + const inspect = (): void => { + if (connection.generation.getSnapshot() !== undefined) finish(); + }; + const aborted = (): void => { + finish(new Error("Remote stream retry aborted", { cause: signal.reason })); + }; + const dispose = connection.generation.subscribe(inspect); + subscription.dispose = dispose; + if (subscription.finished) dispose(); + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + else inspect(); + }); +} + +/** + * Mark a terminal escape before it crosses the stream boundary: consumers + * discriminate failures by code, so an unmarked throw reads as a local bug. + * Marked failures pass through verbatim. The carrier class never escapes as a + * terminal outcome — it stays the retry-internal signal fed to `carrierFailed` + * and the `ended(true)` retry trigger. + */ +function terminalStreamFailure(error: unknown): Error { + return ( + remoteErrorOf(error) ?? + new RemoteError( + "gateway/internal", + error instanceof Error ? error.message : String(error), + {}, + { cause: error }, + ) + ); +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + +async function closeRemoteStreamIterator( + iterator: AsyncIterator>, +): Promise { + try { + await iterator.return?.(); + } catch { + // The disposed logical stream has no remaining consumer for cancellation failures. + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts new file mode 100644 index 000000000..764a4eb79 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/snapshot-stream.ts @@ -0,0 +1,96 @@ +/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteStream } from "./remote-stream.ts"; + +/** Host-side stream protocol violation, marked so consumers surface it as an error state. */ +function protocolViolation(message: string): RemoteError<"gateway/internal"> { + return new RemoteError("gateway/internal", message, {}); +} + +/** Domain operations for one snapshot stream. */ +export interface RemoteSnapshotStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string; + /** Distinguish the opening snapshot from later deltas. */ + readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot; + /** Atomically replace the domain model from a complete snapshot. */ + readonly replace: (snapshot: Snapshot) => void; + /** Apply one incremental update after the generation snapshot. */ + readonly update: (delta: Delta) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** + * Consumes generations that each contain exactly one opening snapshot followed by deltas. + * + * The previous domain snapshot remains published while the underlying stream retries. A + * replacement becomes accepted only after the domain owner applies it successfully. + */ +export class RemoteSnapshotStream { + private started = false; + private disposed = false; + private done: Promise | undefined; + + /** + * @param stream - reconnecting physical-generation stream. + * @param options - frame discriminator and domain state destinations. + */ + constructor( + private readonly stream: RemoteStream, + private readonly options: RemoteSnapshotStreamOptions, + ) {} + + /** Start the single consumer; repeated calls are inert. */ + start(): void { + if (this.started) return; + this.started = true; + this.done = this.consume(); + } + + /** Replace the active physical generation without discarding the published snapshot. */ + restart(): void { + this.stream.restart(); + } + + /** + * Permanently stop the stream and wait for its consumer to become quiescent. + * @returns when no generation or callback can still run. + */ + async dispose(): Promise { + this.disposed = true; + await this.stream.dispose(); + await this.done; + } + + private async consume(): Promise { + let generation = 0; + let snapshotSeen = false; + try { + for await (const item of this.stream) { + if (item.generation !== generation) { + generation = item.generation; + snapshotSeen = false; + } + if (this.options.isSnapshot(item.value)) { + if (snapshotSeen) { + throw protocolViolation(`${this.options.name} emitted more than one opening snapshot`); + } + this.options.replace(item.value); + snapshotSeen = true; + item.accept(); + continue; + } + if (!snapshotSeen) { + throw protocolViolation( + `${this.options.name} emitted an update before its opening snapshot`, + ); + } + this.options.update(item.value); + } + } catch (error) { + if (!this.disposed) this.options.failed(error); + } + } +} diff --git a/apps/web-ui/packages/api/gateway/src/client/stream-client.ts b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts new file mode 100644 index 000000000..50ee9eca4 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/client/stream-client.ts @@ -0,0 +1,515 @@ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +/** Browser owner for the Gateway multiplexed Remote stream socket. */ + +import { + parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, + type RemoteStreamClientMessage, + type RemoteStreamServerMessage, +} from "../stream-protocol.ts"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; + +/** Physical Remote stream socket failure that may be retried by a domain transport. */ +export class RemoteStreamCarrierError extends Error { + /** + * @param message - physical carrier failure description. + * @param options - optional causal error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "RemoteStreamCarrierError"; + } +} + +interface SocketWaiter { + readonly revision: number; + resolve(socket: WebSocket): void; + reject(error: unknown): void; +} + +interface UplinkPump { + /** Settles once the pump has stopped sending. */ + readonly done: Promise; + /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */ + stop(): void; +} + +/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */ +interface LogicalStream { + readonly inbox: StreamInbox; + pump: UplinkPump | undefined; +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; + +/** + * Keep one physical WebSocket and share it among independently cancellable + * Remote streams. A carrier that supplies an in-process stream opener never + * starts one. + */ +export class RemoteStreamMuxClient { + private socket: WebSocket | undefined; + private cancelCandidate: ((error: Error) => void) | undefined; + private keepAlive: Promise | undefined; + private revision = 0; + private readonly streams = new Map(); + private readonly waiters = new Set(); + private running = false; + private disposed = false; + + /** Ensure a physical attempt exists, following the current attempt once if needed. */ + start(): void { + if (this.disposed) return; + this.running = true; + if (this.socket?.readyState === WebSocket.OPEN) return; + const pending = this.keepAlive; + if (pending === undefined) this.maintain(); + else + void pending.then(() => { + this.maintain(); + }); + } + + /** Cancel the current socket or retry wait and start a fresh attempt immediately. */ + reconnect(): void { + if (!this.running || this.disposed) return; + const failure = new RemoteStreamCarrierError("api gateway: Remote stream reconnect requested"); + const pending = this.keepAlive; + this.revision++; + this.cancelCandidate?.(failure); + const socket = this.socket; + if (socket !== undefined) { + this.socket = undefined; + this.failAll(failure); + socket.close(4000, "reconnect requested"); + } + if (pending === undefined) this.maintain(); + else + void pending.then(() => { + this.maintain(); + }); + } + + /** + * Open one logical stream on the persistent physical connection. + * If no physical attempt is active, opening waits for Connection to request + * one or for the signal to abort. + * @param endpoint - Typert Remote stream endpoint. + * @param payload - endpoint request encoded on the wire. + * @param signal - cancellation for this logical stream. + * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()` + * runs when the stream finishes, and its failure cancels the stream and fails the downlink. + * @returns Host items until completion, cancellation, or failure. + */ + async *open( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ): AsyncGenerator { + signal.throwIfAborted(); + const streamId = randomUUID(); + const inbox = new StreamInbox(); + const stream: LogicalStream = { inbox, pump: undefined }; + let carrier: WebSocket | undefined; + let opened = false; + let terminal = false; + const abort = (): void => { + inbox.fail(signal.reason); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + const socket = await this.waitForSocket(signal); + signal.throwIfAborted(); + carrier = socket; + this.streams.set(streamId, stream); + this.send(socket, { type: "open", streamId, endpoint, payload }); + opened = true; + if (uplink !== undefined) + stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox); + while (true) { + const frame = await inbox.next(); + signal.throwIfAborted(); + if (frame.type === "item") { + yield frame.value; + continue; + } + terminal = true; + if (frame.type === "error") { + throw new RemoteError( + frame.error.code as never, + frame.error.message, + frame.error.details as never, + ); + } + return; + } + } finally { + signal.removeEventListener("abort", abort); + this.streams.delete(streamId); + stream.pump?.stop(); + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + this.send(carrier, { type: "cancel", streamId }); + } + // The old generation's pump has stopped before a supervisor reopens the next one. + if (stream.pump !== undefined) await stream.pump.done; + } + } + + /** + * Send the caller's uplink items on this generation's socket. `stop()` + * interrupts a pump blocked on `uplink.next()` and releases the iterator: a + * handle's queue closes at once, so `send()` throws from then on, and any + * other iterator's `return()` is invoked without being awaited because a + * generator blocked in `next()` only completes it once it yields. + */ + private pumpUplink( + socket: WebSocket, + streamId: string, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: StreamInbox, + ): UplinkPump { + let stopped: PromiseWithResolvers> | undefined; + const interruption: IteratorReturnResult = { value: undefined, done: true }; + const uplinkIterator = uplink[Symbol.asyncIterator](); + const state = { stopping: false, exhausted: false, released: false }; + const release = (): void => { + if (state.released || state.exhausted) return; + state.released = true; + if (uplink instanceof ClientUplinkQueue) uplink.close(); + void Promise.resolve() + .then(() => uplinkIterator.return?.()) + .catch(() => undefined); + }; + const done = (async (): Promise => { + try { + while (true) { + const stoppingBeforeRead = state.stopping; + if (stoppingBeforeRead) return; + // A stop promise belongs to one read, not the whole uplink history. + stopped = Promise.withResolvers>(); + const next = await Promise.race([uplinkIterator.next(), stopped.promise]); + stopped = undefined; + if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) + return; + if (next.done === true) { + state.exhausted = true; + break; + } + this.send(socket, { type: "item", streamId, value: next.value }); + } + this.send(socket, { type: "end", streamId }); + } catch (error) { + inbox.fail(error); + } finally { + stopped = undefined; + release(); + } + })(); + return { + done, + stop: () => { + if (state.stopping) return; + state.stopping = true; + stopped?.resolve(interruption); + release(); + }, + }; + } + + /** + * Permanently stop the carrier, close the physical socket, and fail every + * active logical stream. + * @returns once the active connection attempt has stopped. + */ + async close(): Promise { + if (!this.disposed) { + this.disposed = true; + this.running = false; + const error = new Error("api gateway: Remote stream client disposed"); + this.failAll(error); + for (const waiter of [...this.waiters]) waiter.reject(error); + this.cancelCandidate?.(error); + const socket = this.socket; + this.socket = undefined; + socket?.close(1000, "disposed"); + } + await this.keepAlive; + } + + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()); + const connecting = new Promise((resolve, reject) => { + let settled = false; + const rejectCandidate = (error: Error): void => { + settled = true; + socket.removeEventListener("open", opened); + socket.removeEventListener("error", failed); + socket.removeEventListener("message", received); + socket.removeEventListener("close", closed); + this.cancelCandidate = undefined; + socket.close(); + reject(error); + }; + const opened = (): void => { + settled = true; + this.cancelCandidate = undefined; + this.socket = socket; + for (const waiter of [...this.waiters]) waiter.resolve(socket); + resolve(socket); + }; + const failed = (): void => { + if (!settled) { + rejectCandidate( + new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed to open"), + ); + return; + } + const error = new RemoteStreamCarrierError("api gateway: Remote stream WebSocket failed"); + this.lost(socket, error); + socket.close(); + }; + const closed = (): void => { + if (!settled) { + rejectCandidate( + new RemoteStreamCarrierError( + "api gateway: Remote stream WebSocket closed before opening", + ), + ); + return; + } + this.lost(socket); + }; + const received = (event: MessageEvent): void => { + this.receive(socket, event.data); + }; + this.cancelCandidate = rejectCandidate; + socket.addEventListener("open", opened, { once: true }); + socket.addEventListener("error", failed, { once: true }); + socket.addEventListener("message", received); + socket.addEventListener("close", closed, { once: true }); + }); + return connecting; + } + + private waitForSocket(signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket); + if (this.disposed) + return Promise.reject(new Error("api gateway: Remote stream client disposed")); + if (!this.running) + return Promise.reject(new Error("api gateway: Remote stream client not started")); + return new Promise((resolve, reject) => { + const aborted = (): void => { + waiter.reject(signal.reason); + }; + const cleanup = (): void => { + this.waiters.delete(waiter); + signal.removeEventListener("abort", aborted); + }; + const waiter: SocketWaiter = { + revision: this.revision, + resolve: (socket) => { + cleanup(); + resolve(socket); + }, + reject: (error) => { + cleanup(); + // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error); + }, + }; + this.waiters.add(waiter); + signal.addEventListener("abort", aborted, { once: true }); + }); + } + + private receive(socket: WebSocket, data: unknown): void { + if (socket !== this.socket) return; + try { + if (typeof data !== "string") + throw new Error("api gateway: Remote stream WebSocket requires text messages"); + const frame = parseRemoteStreamServerMessage(data); + const stream = this.streams.get(frame.streamId); + if (stream === undefined) return; + stream.inbox.push(frame); + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.type !== "item") stream.pump?.stop(); + } catch (error) { + const failure = new RemoteStreamCarrierError("api gateway: invalid Remote stream frame", { + cause: error, + }); + this.failAll(failure); + this.lost(socket, failure); + socket.close(4002, "invalid Remote stream frame"); + } + } + + private lost( + socket: WebSocket, + error: RemoteStreamCarrierError = new RemoteStreamCarrierError( + "api gateway: Remote stream WebSocket closed", + ), + ): void { + if (this.socket !== socket) return; + this.socket = undefined; + this.failAll(error); + } + + private maintain(): void { + if (!this.running || this.disposed) return; + if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return; + const revision = this.revision; + const task = this.connect().then( + () => undefined, + (error: unknown) => { + if (!this.running) return; + for (const waiter of [...this.waiters]) { + if (waiter.revision <= revision) waiter.reject(error); + } + }, + ); + this.keepAlive = task; + void task.then(() => { + this.keepAlive = undefined; + }); + } + + private failAll(error: unknown): void { + for (const stream of this.streams.values()) { + stream.inbox.fail(error); + stream.pump?.stop(); + } + } + + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + socket.send(JSON.stringify(message)); + } +} + +class StreamInbox { + private readonly frames = new Deque(); + private wake: (() => void) | undefined; + private failure: Error | undefined; + + push(frame: RemoteStreamServerMessage): void { + if (this.failure !== undefined) return; + this.frames.pushBack(frame); + this.wake?.(); + this.wake = undefined; + } + + fail(error: unknown): void { + if (this.failure !== undefined) return; + this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }); + this.frames.clear(); + this.wake?.(); + this.wake = undefined; + } + + async next(): Promise { + while (this.frames.size === 0) { + if (this.failure !== undefined) throw this.failure; + await new Promise((resolve) => { + this.wake = resolve; + }); + } + return this.frames.popFront() as RemoteStreamServerMessage; + } +} + +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. One consumer reads it, one read at a time. + */ +export class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque(); + private ended = false; + private closed = false; + private wake: (() => void) | undefined; + + /** @param endpoint - canonical Remote endpoint named by failures. */ + constructor(private readonly endpoint: string) {} + + /** + * Queue one item for the carrier. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} after `end()` or once the stream has terminated. + */ + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`); + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`); + this.items.pushBack(item); + this.signal(); + } + + /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */ + end(): void { + if (this.ended || this.closed) return; + this.ended = true; + this.signal(); + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return; + this.closed = true; + this.items.clear(); + this.signal(); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + /** + * Take the next queued item, waiting for one; ends after `end()` or `close()`. + * @returns the next item, or the end of the uplink. + * @throws {Error} when a read is already pending. + */ + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE; + if (this.items.size > 0) return { value: this.items.popFront(), done: false }; + if (this.ended) return UPLINK_DONE; + if (this.wake !== undefined) + throw new Error(`client api: ${this.endpoint} uplink has one pending read`); + await new Promise((resolve) => { + this.wake = resolve; + }); + } + } + + /** + * The carrier is done with the uplink: close it. + * @returns the end of the uplink. + */ + return(): Promise> { + this.close(); + return Promise.resolve(UPLINK_DONE); + } + + private signal(): void { + const wake = this.wake; + this.wake = undefined; + wake?.(); + } +} + +function remoteStreamUrl(): string { + // The mux route is registered absolute; a page resolves its document-relative + // form against its own document base. A shell-owned Host on another origin + // supplies that base through the transport. + const globals = globalThis as { __DSH_TRANSPORT__?: { streamBaseUrl?: string } }; + const url = new URL( + REMOTE_STREAM_MUX_PATH.slice(1), + globals.__DSH_TRANSPORT__?.streamBaseUrl ?? document.baseURI, + ); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.href; +} diff --git a/apps/web-ui/packages/api/gateway/src/index.ts b/apps/web-ui/packages/api/gateway/src/index.ts new file mode 100644 index 000000000..5716fc0ba --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/index.ts @@ -0,0 +1,1685 @@ +/** + * Live Typert Remote dispatch over Cordis Services and registered providers. + * Unary transport and response envelopes belong to Connection; live Remote + * streams use the Gateway-owned WebSocket mux. + * @module @deepseek-ai/dsh-api-gateway + */ + +import { randomUUID } from "node:crypto"; +import { Context, Service, symbols } from "@deepseek-ai/cordis"; +import { + OperatorPeer, + type ConnectionRpcAttachment, + type ConnectionRpcHandler, +} from "@deepseek-ai/dsh-client-connection"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { WebUpgradeRoute } from "@deepseek-ai/dsh-host-webserver"; +import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout"; +import type {} from "@deepseek-ai/dsh-cmdline"; +import z from "@deepseek-ai/schemastery"; +export type { TypertGatewayFaultDetails } from "./remote-error-codes.ts"; +import { + RemoteError, + isRemoteJsonValue, + remoteErrorOf, + remoteMethods, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type RemoteInvocation, + type TypertCodec, + type TypertGatewayBinding, +} from "@deepseek-ai/dsh-typert-protocol"; +import type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "./types.ts"; +import { RemoteStreamMuxServer, rejectRemoteStreamUpgrade } from "./stream-server.ts"; +import { + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_READY, + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_STREAM_MUX_PATH, + isRemoteEventAgentId, + parseRemoteEventResult, + projectRemoteEventRequest, + restoreRemoteEventRejection, + type RemoteEventCancellationFrame, + type RemoteEventClientId, + type RemoteEventEmitFrame, + type RemoteEventHostInfo, + type RemoteEventId, + type RemoteEventInvocationFrame, + type RemoteEventReadyFrame, + type RemoteStreamFailure, +} from "./stream-protocol.ts"; + +export type { + InvokeRemoteRequest, + TypertGateway, + TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventContext, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "./types.ts"; +export type { RemoteEventHostInfo } from "./stream-protocol.ts"; + +interface GatewayErrorOptions { + readonly cause?: unknown; + readonly field?: string; +} + +interface ResolvedBinding { + readonly binding: TypertGatewayBinding; + readonly original: object; +} + +interface PreparedInvocation { + readonly endpoint: string; + readonly descriptor: InvocationDescriptor; + /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ + readonly receiver: object; + readonly args: readonly unknown[]; + readonly method: (...args: never[]) => unknown; + readonly invocation: GatewayInvocation; +} + +/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ +interface UplinkSource { + /** Carrier items; an immediately ended iterable when the carrier has none. */ + readonly source: AsyncIterable; + /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ + readonly codec: TypertCodec; + readonly endpoint: string; + /** Fail the logical stream with a Remote failure as the reason. */ + readonly abort: (reason: unknown) => void; +} + +interface RegisteredRemoteEventSource { + readonly lifetime: AbortController; + readonly done: Promise; + readonly host: RemoteEventHostInfo; +} + +interface RemoteEventClient { + readonly id: RemoteEventClientId; + readonly queue: RemoteEventQueue; + readonly signal: AbortSignal; + readonly deliveries: Map; +} + +interface PendingRemoteEvent { + readonly id: RemoteEventId; + readonly source: TypertRemoteEventInvocation; + readonly frame: RemoteEventInvocationFrame; + readonly deliveries: Set; + releaseContext: () => void; + releaseSignal: () => void; +} + +type ConnectionRpcResult = Awaited>; +type ConnectionRpcError = Extract["error"]; +const NEVER_ABORTED_SIGNAL = new AbortController().signal; +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000; +const DEFAULT_STREAM_INBOX_BYTES = 262_144; +const EMPTY_ASYNC_ITERABLE: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +}; +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; +const SRC_JSON_CODEC: TypertCodec = { mode: "src-json" }; + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ + readonly websocketHeartbeatIntervalMs?: number; + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number; +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number; + readonly streamInboxBytes: number; +} + +/** + * Dispatch failure produced outside the invoked business method. Rides the + * shared Remote failure vocabulary, so its code crosses the wire instead of + * folding to `internal`. + */ +export class TypertGatewayError extends RemoteError { + /** Canonical `/` endpoint. */ + readonly endpoint: string; + /** Affected wire field when the failure is field-specific. */ + readonly field: string | undefined; + + /** + * Construct a Gateway failure without embedding boundary values in its message. + * @param code - stable failure category. + * @param endpoint - canonical Remote endpoint. + * @param message - correction-oriented diagnostic without sensitive values. + * @param options - optional field and contained cause. + */ + constructor( + code: TypertGatewayErrorCode, + endpoint: string, + message: string, + options: GatewayErrorOptions = {}, + ) { + super( + code, + `typert gateway: ${endpoint}: ${message}`, + { endpoint, ...(options.field === undefined ? {} : { field: options.field }) }, + options.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = "TypertGatewayError"; + this.endpoint = endpoint; + this.field = options.field; + } +} + +/** + * Resolve strict generated definitions or conservative SRC markers against + * current Cordis Services and Typert providers. + * @typert service typertGateway + */ +export class TypertGatewayService extends Service implements TypertGateway { + static inject = ["typert"]; + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z + .number() + .step(1) + .min(1) + .max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), + }); + + /** Carrier adapter shared by the WebSocket mux and local Host transports. */ + readonly wireStream: TypertGatewayWireStream = { + open: (endpoint, payload, uplink, peer, signal) => + this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), + failure: (error) => rpcError(error), + }; + + private srcClaims: ReadonlySet | undefined; + private inProcessOperator: PeerScope | undefined; + private remoteEvents: RegisteredRemoteEventSource | undefined; + private readonly remoteEventClients = new Map(); + private readonly pendingRemoteEvents = new Map(); + + /** + * Register the Gateway against the active Typert registry. + * WebSocket admission waits for launcher-owned application readiness when supplied; + * direct invocation and in-process streams remain available independently. + * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. + */ + constructor(ctx: Context, config: Config) { + super(ctx, "typertGateway"); + const resolved = config as ResolvedConfig; + ctx.on("internal/service", () => { + this.srcClaims = undefined; + }); + ctx.inject(["connection"], (connectionCtx) => { + connectionCtx.connection.rpc.intercept( + "/api", + (endpoint) => this.claimsEndpoint(endpoint), + (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), + ); + }); + ctx.inject(["connection", "webServer"], (webCtx) => { + const listen = (): void => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), + this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, + ); + webCtx.effect(function* () { + yield () => mux.close(); + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + const admission = webCtx.connection.admit(req); + if ("rejection" in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection); + return; + } + mux.handleUpgrade(req, socket, head, admission.peer); + }, + }; + yield webCtx.webServer.registerUpgrade(route); + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`); + }; + // Existing pages reconnect before the new Host prints its URL. No stream + // may enter until the launcher has activated and audited its controllers. + const ready = webCtx.get("appReady"); + if (ready === undefined) listen(); + else + webCtx.effect(() => { + let closed = false; + const cancel = ready.onReady(() => { + if (!closed) listen(); + }); + return () => { + closed = true; + cancel(); + }; + }, "api-gateway: application readiness"); + }); + } + + /** + * Check for an active Client event stream. + * @returns whether a stream is open and has not been cancelled. + */ + hasLiveClient(): boolean { + for (const client of this.remoteEventClients.values()) { + if (!client.signal.aborted) return true; + } + return false; + } + + /** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. + * @returns disposer removing this source and cancelling its active streams. + */ + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise { + if (this.remoteEvents !== undefined) { + throw new Error("typert gateway: forwarded Remote event source is already registered"); + } + const lifetime = new AbortController(); + const stream = source(lifetime.signal); + const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { + if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return; + this.closeRemoteEvents(error); + this.remoteEvents = undefined; + lifetime.abort(error); + }); + const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } }; + this.remoteEvents = registration; + return async () => { + if (this.remoteEvents === registration) { + this.remoteEvents = undefined; + const error = new Error("typert gateway: forwarded Remote event source was removed"); + registration.lifetime.abort(error); + this.closeRemoteEvents(error); + } + await registration.done; + }; + } + + private claimsEndpoint(endpoint: string): boolean { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true; + const segments = endpoint.split("/"); + if (segments.length !== 2 || segments[0] === "" || segments[1] === "") return false; + if ( + this.ctx.typert.local.get(endpoint) !== undefined || + this.ctx.typert.local.hasSeen(endpoint) + ) + return true; + this.srcClaims ??= this.collectSrcClaims(); + return this.srcClaims.has(endpoint); + } + + private collectSrcClaims(): ReadonlySet { + const claims = new Set(); + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== "service") continue; + const receiver: unknown = this.ctx.get(serviceKey); + if (!isObject(receiver)) continue; + const original = originalOf(receiver); + const binding: unknown = Reflect.get(original, "typertRemote"); + if (!isObject(binding) || typeof Reflect.get(binding, "namespace") !== "string") continue; + const namespace = Reflect.get(binding, "namespace") as string; + for (const candidate of remoteMethods(original)) { + claims.add(endpointOf(namespace, candidate.exportName ?? candidate.method)); + } + } + return claims; + } + + /** + * Invoke one live Remote method through strict generated reflection or SRC markers. + * @param request - decoded endpoint and exact named wire arguments. + * @returns the business result without output decoding. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. + */ + async invoke(request: InvokeRemoteRequest): Promise { + return this.invokePrepared(await this.prepareInvocation(request, new AbortController())); + } + + private async invokePrepared(prepared: PreparedInvocation): Promise { + if (prepared.descriptor.mode !== undefined) { + throw new TypertGatewayError( + "gateway/signature-invalid", + prepared.endpoint, + "stream Remote methods must be opened through the stream carrier", + ); + } + + try { + return (await Reflect.apply(prepared.method, prepared.receiver, prepared.args)) as unknown; + } catch (error) { + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error); + throw error; + } finally { + // A unary call's uplink is readable only while the method runs. + await prepared.invocation.close(); + } + } + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. + * @returns a cancellation-aware iterable over the business results. + */ + async stream(request: InvokeRemoteRequest): Promise> { + return this.openStream(request, new AbortController()); + } + + /** + * `control` belongs to the logical stream: a rejected uplink item aborts it + * with the Remote failure as the reason so the carrier delivers that failure. + */ + private async openStream( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise> { + const prepared = await this.prepareInvocation(request, control); + if (prepared.descriptor.mode === undefined) { + await prepared.invocation.close(); + throw new TypertGatewayError( + "gateway/signature-invalid", + prepared.endpoint, + "unary Remote methods cannot be opened through the stream carrier", + ); + } + let source: unknown; + try { + source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown; + } catch (error) { + await prepared.invocation.close(); + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error); + throw error; + } + if (!isIterable(source)) { + await prepared.invocation.close(); + throw new TypertGatewayError( + "gateway/result-invalid", + prepared.endpoint, + "stream Remote method did not return Iterable or AsyncIterable", + { field: "result" }, + ); + } + return cancellableStream(source, prepared.endpoint, prepared.invocation); + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { + try { + const result = parseRemoteEventResultPayload(payload); + const client = this.remoteEventClients.get(result.clientId); + if (client === undefined) { + throw new Error("typert gateway: Remote event result identifies no active event stream"); + } + this.receiveRemoteEventResult(client, result); + return { ok: true, value: undefined }; + } catch (error) { + return rpcFailure(error); + } + } + return this.invokeRpc(endpoint, payload, signal, peer); + } + + private async openWireStream( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, + signal: AbortSignal, + control: AbortController, + ): Promise> { + if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. + releaseUplink(uplink); + return this.openRemoteEvents(payload, signal); + } + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control); + } + + /** + * The Peer an in-process carrier speaks for when it names none: the + * operator's Peer when Connection is mounted, otherwise an operator scope the + * Gateway owns for its own lifetime. + * @returns the operator Peer. + */ + private operatorPeer(): PeerScope { + const connection = this.ctx.get("connection"); + if (connection !== undefined) return connection.operator; + this.inProcessOperator ??= new OperatorPeer(this.ctx); + return this.inProcessOperator; + } + + private async *openRemoteEvents( + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator< + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + | RemoteEventReadyFrame + > { + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") || + !isObject(payload.args) || + !isPlainObject(payload.args) || + Reflect.ownKeys(payload.args).length !== 0 + ) { + throw new TypertGatewayError( + "gateway/arguments-invalid", + REMOTE_EVENT_STREAM_ENDPOINT, + "forwarded Remote event stream requires an empty args object", + ); + } + const registration = this.remoteEvents; + if (registration === undefined) { + throw new TypertGatewayError( + "gateway/service-unavailable", + REMOTE_EVENT_STREAM_ENDPOINT, + "forwarded Remote event source is unavailable", + ); + } + const lifetime = AbortSignal.any([signal, registration.lifetime.signal]); + let clientId = randomUUID() as RemoteEventClientId; + while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId; + const client: RemoteEventClient = { + id: clientId, + queue: new RemoteEventQueue(), + signal: lifetime, + deliveries: new Map(), + }; + this.remoteEventClients.set(clientId, client); + for (const pending of this.pendingRemoteEvents.values()) + this.deliverRemoteEvent(pending, client); + try { + yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host }; + yield* client.queue.iterate(lifetime); + } finally { + this.removeRemoteEventClient(client); + } + } + + private async consumeRemoteEvents( + source: AsyncIterable, + signal: AbortSignal, + ): Promise { + for await (const dispatch of source) { + if (signal.aborted) { + if ("context" in dispatch) dispatch.reject(signal.reason); + return; + } + if ("context" in dispatch) this.startRemoteEvent(dispatch); + else this.broadcastRemoteEvent(dispatch); + } + if (!signal.aborted) { + throw new Error("typert gateway: forwarded Remote event source ended unexpectedly"); + } + } + + private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { + assertRemoteEventFrame(frame); + const wire: RemoteEventEmitFrame = { + type: "emit", + event: frame.event, + args: frame.args, + }; + for (const client of this.remoteEventClients.values()) client.queue.push(wire); + } + + private startRemoteEvent(source: TypertRemoteEventInvocation): void { + try { + assertRemoteEventName(source); + if (!isRemoteEventAgentId(source.context.agentId)) { + throw new TypeError( + "typert gateway: scoped Remote events require a non-empty Agent identity", + ); + } + const projected = projectRemoteEventRequest(source.request, source.context.subject); + let id = randomUUID() as RemoteEventId; + while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId; + let releaseContext: () => void; + try { + const dispose = source.context.value.effect( + () => () => { + this.cancelRemoteEvent( + pending, + new Error("typert gateway: Remote event Agent Context was released"), + ); + }, + `api-gateway: Remote event ${JSON.stringify(source.event)}`, + ); + releaseContext = () => { + void dispose(); + }; + } catch { + source.resolve({ kind: "next" }); + return; + } + const signals = new Set(projected.signal === undefined ? [] : [projected.signal]); + const abort = (): void => { + const reason: unknown = [...signals].find((signal) => signal.aborted)?.reason; + this.cancelRemoteEvent( + pending, + reason instanceof Error + ? reason + : new Error("typert gateway: Remote event was cancelled", { cause: reason }), + ); + }; + const pending: PendingRemoteEvent = { + id, + source, + frame: { + type: "waterfall", + event: source.event, + eventId: id, + agentId: source.context.agentId, + request: projected.request, + }, + deliveries: new Set(), + releaseContext, + releaseSignal: () => { + for (const signal of signals) signal.removeEventListener("abort", abort); + }, + }; + this.pendingRemoteEvents.set(id, pending); + for (const signal of signals) signal.addEventListener("abort", abort, { once: true }); + if ([...signals].some((signal) => signal.aborted)) abort(); + else + for (const client of this.remoteEventClients.values()) + this.deliverRemoteEvent(pending, client); + } catch (error) { + source.reject(error); + } + } + + private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.add(client); + client.deliveries.set(pending.id, pending); + client.queue.push(pending.frame); + } + + private receiveRemoteEventResult( + client: RemoteEventClient, + result: ReturnType, + ): void { + const pending = this.pendingRemoteEvents.get(result.eventId); + // Settlement and Client replacement may race the result request. Results + // from a completed event or a superseded delivery are idempotent no-ops. + if (pending === undefined || !pending.deliveries.has(client)) return; + this.removeRemoteEventDelivery(pending, client); + if (result.outcome.kind === "result") { + this.settleRemoteEvent(pending, { + kind: "result", + value: result.outcome.value, + }); + } else if (result.outcome.kind === "rejected") { + this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)); + } else if (pending.deliveries.size === 0) { + this.settleRemoteEvent(pending, { kind: "next" }); + } + } + + private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.delete(client); + client.deliveries.delete(pending.id); + } + + private removeRemoteEventClient(client: RemoteEventClient): void { + this.remoteEventClients.delete(client.id); + for (const pending of [...client.deliveries.values()]) + this.removeRemoteEventDelivery(pending, client); + client.queue.end(); + } + + private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { + this.finishRemoteEvent(pending); + pending.source.resolve(outcome); + } + + private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { + if (this.pendingRemoteEvents.get(pending.id) !== pending) return; + this.finishRemoteEvent(pending); + pending.source.reject(reason); + } + + private finishRemoteEvent(pending: PendingRemoteEvent): void { + this.pendingRemoteEvents.delete(pending.id); + pending.releaseSignal(); + pending.releaseContext(); + const clients = new Set(pending.deliveries); + for (const client of clients) this.removeRemoteEventDelivery(pending, client); + const cancellation: RemoteEventCancellationFrame = { + type: "cancel", + eventId: pending.id, + }; + for (const client of clients) client.queue.push(cancellation); + } + + private closeRemoteEvents(reason: unknown): void { + for (const pending of [...this.pendingRemoteEvents.values()]) { + this.cancelRemoteEvent(pending, reason); + } + for (const client of [...this.remoteEventClients.values()]) client.queue.end(); + } + + private async invokeRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { + try { + const prepared = await this.prepareInvocation( + remoteRequest(endpoint, payload, signal, peer), + new AbortController(), + ); + const value = await this.invokePrepared(prepared); + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. + return encodeRpcResult(value, prepared.descriptor.result); + } catch (error) { + return rpcFailure(error); + } + } + + /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ + private async prepareInvocation( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise { + const endpoint = endpointOf(request.namespace, request.method); + const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint); + assertExactArguments(request.args, descriptor, endpoint); + const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint); + const receiver: unknown = receiverContext.get(descriptor.service); + if (!isObject(receiver)) { + throw new TypertGatewayError( + "gateway/service-unavailable", + endpoint, + `active Service ${JSON.stringify(descriptor.service)} is unavailable`, + ); + } + validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint); + const args = await Promise.all( + descriptor.parameters.map((parameter) => + this.resolveParameter(parameter, request.args, endpoint), + ), + ); + const signal = methodSignal(request, control); + const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + { + source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, + endpoint, + abort: (reason) => { + control.abort(reason); + }, + }, + ); + if (descriptor.cancellation !== undefined) args.push(signal); + // The method runs on a Service view bound to a Context carrying this call: + // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` + // is this call and nothing travels through the parameter list. The view + // resolves the Service the plain read above already found. + const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object; + const implementation = descriptor.implementation ?? descriptor.method; + const method: unknown = Reflect.get(callReceiver, implementation); + if (typeof method !== "function") { + throw new TypertGatewayError( + "gateway/method-unavailable", + endpoint, + `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, + ); + } + return { + endpoint, + descriptor, + receiver: callReceiver, + args, + method: method as (...args: never[]) => unknown, + invocation, + }; + } + + private resolveDescriptor( + namespace: string, + method: string, + endpoint: string, + ): InvocationDescriptor { + const strict = this.ctx.typert.local.get(endpoint); + if (strict !== undefined) return strict; + if (this.ctx.typert.local.hasSeen(endpoint)) { + throw new TypertGatewayError( + "gateway/definition-unavailable", + endpoint, + "its strict definition was withdrawn and SRC fallback is forbidden", + ); + } + return this.resolveSrcDescriptor(namespace, method, endpoint); + } + + private resolveSrcDescriptor( + namespace: string, + method: string, + endpoint: string, + ): InvocationDescriptor { + const candidates: InvocationDescriptor[] = []; + for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { + if (definition.type !== "service") continue; + const receiver: unknown = this.ctx.get(serviceKey); + if (!isObject(receiver)) continue; + const original = originalOf(receiver); + const value: unknown = Reflect.get(original, "typertRemote"); + if (value === undefined) continue; + const binding = readBinding(value, original, serviceKey, endpoint); + if (binding.namespace !== namespace) continue; + const marker = remoteMethods(original).find( + (candidate) => (candidate.exportName ?? candidate.method) === method, + ); + if (marker === undefined) continue; + candidates.push(this.srcDescriptor(binding, marker, method, endpoint)); + } + if (candidates.length === 0) { + throw new TypertGatewayError( + "gateway/invocation-unavailable", + endpoint, + "no active Remote method exports this endpoint", + ); + } + if (candidates.length > 1) { + throw new TypertGatewayError( + "gateway/ambiguous-endpoint", + endpoint, + `multiple active Services export this endpoint: ${candidates + .map((candidate) => candidate.service) + .sort() + .join(", ")}`, + ); + } + return candidates[0] as InvocationDescriptor; + } + + private srcDescriptor( + binding: TypertGatewayBinding, + marker: ReturnType[number], + method: string, + endpoint: string, + ): InvocationDescriptor { + const names = methodParameterNames(binding.service, marker.method, endpoint); + const signalIndex = names.indexOf("signal"); + if (signalIndex >= 0 && signalIndex !== names.length - 1) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + "SRC cancellation parameter signal must be the final parameter", + { field: "signal" }, + ); + } + const cancellation = signalIndex >= 0 ? { parameter: "signal" as const } : undefined; + const businessNames = cancellation === undefined ? names : names.slice(0, -1); + const parameters: InvocationParameterDescriptor[] = []; + const wires = new Set(); + for (const name of businessNames) { + const matches = this.ctx.typert.lookups + .definitions() + .filter((definition) => definition.parameter === name); + if (matches.length > 1) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `parameter ${JSON.stringify(name)} matches multiple lookup providers`, + { field: name }, + ); + } + const match = matches[0]; + const parameter: InvocationParameterDescriptor = + match === undefined + ? { name, wire: name, source: "json", codec: { mode: "src-json" } } + : { + name, + wire: match.wire, + source: "lookup", + lookup: match.key, + codec: { mode: "src-json" }, + }; + if (wires.has(parameter.wire)) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `multiple parameters use wire field ${JSON.stringify(parameter.wire)}`, + { field: parameter.wire }, + ); + } + wires.add(parameter.wire); + parameters.push(parameter); + } + + let receiver: InvocationDescriptor["invocation"] = { kind: "direct" }; + if (marker.invocation.kind === "context") { + const provider = this.ctx.typert.contexts.getHost(marker.invocation.context); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/context-unavailable", + endpoint, + `Context provider ${JSON.stringify(marker.invocation.context)} is unavailable`, + ); + } + if (wires.has(provider.wire)) { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `Context identity conflicts with wire field ${JSON.stringify(provider.wire)}`, + { field: provider.wire }, + ); + } + receiver = { + kind: "context", + context: marker.invocation.context, + wire: provider.wire, + codec: { mode: "src-json" }, + }; + } + + return { + id: `src:${binding.serviceKey}#${endpoint}`, + service: binding.serviceKey, + namespace: binding.namespace, + method, + ...(marker.method === method ? {} : { implementation: marker.method }), + ...(marker.mode === undefined ? {} : { mode: marker.mode }), + invocation: receiver, + parameters, + ...(cancellation === undefined ? {} : { cancellation }), + result: { mode: "src-json" }, + }; + } + + private async resolveReceiverContext( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + if (descriptor.invocation.kind === "direct") return this.ctx; + const invocation = descriptor.invocation; + const provider = this.ctx.typert.contexts.getHost(invocation.context); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/context-unavailable", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} is unavailable`, + ); + } + if ( + provider.wire !== invocation.wire || + (invocation.codec.mode === "strict" && + provider.wireTypeSymbol !== invocation.codec.typeSymbol) + ) { + throw new TypertGatewayError( + "gateway/provider-mismatch", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} does not match its strict definition`, + { field: invocation.wire }, + ); + } + const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire); + let context: Context | undefined; + try { + context = await provider.resolve(identity); + } catch (cause) { + if (remoteErrorOf(cause) !== undefined) throw cause; + throw new TypertGatewayError( + "gateway/context-failed", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} failed`, + { cause, field: invocation.wire }, + ); + } + if (context === undefined) { + throw new TypertGatewayError( + "gateway/context-not-found", + endpoint, + `Context provider ${JSON.stringify(invocation.context)} did not resolve the requested identity`, + { field: invocation.wire }, + ); + } + return context; + } + + private async resolveParameter( + parameter: InvocationParameterDescriptor, + args: Readonly>, + endpoint: string, + ): Promise { + // An absent field reached assertExactArguments' allowance, so this parameter + // takes undefined; a present-but-undefined field is not JSON-safe input and + // still fails decode. Lookup ids are never omissible, so absence here only + // ever belongs to a json parameter. + if (!Object.hasOwn(args, parameter.wire)) return undefined; + const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire); + if (parameter.source === "json") return value; + const key = parameter.lookup; + /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ + if (key === undefined) { + throw new TypertGatewayError( + "gateway/lookup-unavailable", + endpoint, + `lookup parameter ${JSON.stringify(parameter.name)} has no provider key`, + { field: parameter.wire }, + ); + } + const provider = this.ctx.typert.lookups.get(key); + if (provider === undefined) { + throw new TypertGatewayError( + "gateway/lookup-unavailable", + endpoint, + `lookup provider ${JSON.stringify(key)} is unavailable`, + { field: parameter.wire }, + ); + } + if ( + provider.wire !== parameter.wire || + (parameter.codec.mode === "strict" && provider.wireTypeSymbol !== parameter.codec.typeSymbol) + ) { + throw new TypertGatewayError( + "gateway/provider-mismatch", + endpoint, + `lookup provider ${JSON.stringify(key)} does not match its strict definition`, + { field: parameter.wire }, + ); + } + let resolved: unknown; + try { + resolved = await provider.resolve(value); + } catch (cause) { + if (remoteErrorOf(cause) !== undefined) throw cause; + throw new TypertGatewayError( + "gateway/lookup-failed", + endpoint, + `lookup provider ${JSON.stringify(key)} failed`, + { cause, field: parameter.wire }, + ); + } + if (resolved === undefined) { + throw new TypertGatewayError( + "gateway/lookup-not-found", + endpoint, + `lookup provider ${JSON.stringify(key)} did not resolve the requested identity`, + { field: parameter.wire }, + ); + } + return resolved; + } +} + +function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { + const attachments: ConnectionRpcAttachment[] = []; + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ path: [...path], bytes }); + return null; + }; + const encoded = + codec.mode === "strict" + ? (codec.encode?.(value, writeBytes) ?? value) + : encodeRuntimeResult(value, writeBytes); + return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) }; +} + +function encodeRuntimeResult( + input: unknown, + writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, +): unknown { + const path: (string | number)[] = []; + const ancestors = new Set(); + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input; + if (input !== null && typeof input === "object" && !(input instanceof Uint8Array)) { + const toJSON: unknown = Reflect.get(input, "toJSON"); + if (typeof toJSON === "function") value = Reflect.apply(toJSON, input, [key]); + } + if (value instanceof Uint8Array) return writeBytes(value, path); + if (typeof value !== "object" || value === null) return value; + if (value instanceof Number || value instanceof String || value instanceof Boolean) + return value.valueOf(); + if (ancestors.has(value)) throw new TypeError("gateway: circular RPC result"); + ancestors.add(value); + let copy: object; + if (Array.isArray(value)) { + const items: unknown[] = []; + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) + items.push(child(value[index], index)); + copy = items; + } else { + const fields: Record = {}; + for (const key of Object.keys(value)) { + const item: unknown = Reflect.get(value, key); + // A toJSON method on the projected object must not run a second time. + if (key === "toJSON" && typeof item === "function") continue; + const extracted = child(item, key); + if (key === "__proto__") + Object.defineProperty(fields, key, { value: extracted, enumerable: true }); + else fields[key] = extracted; + } + copy = fields; + } + ancestors.delete(value); + return copy; + }; + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== "object" || value === null) return value; + path.push(key); + const extracted = extract(value, String(key)); + path.pop(); + return extracted; + }; + return extract(input, "value"); +} + +type RemoteEventWireFrame = + RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame; + +/** Pull-driven queue owned by one connected Client event generation. */ +class RemoteEventQueue { + private readonly frames = new Deque(); + private waiter: (() => void) | undefined; + private closed = false; + + push(frame: RemoteEventWireFrame): void { + if (this.closed) return; + this.frames.pushBack(frame); + this.waiter?.(); + } + + end(): void { + if (this.closed) return; + this.closed = true; + this.waiter?.(); + } + + async *iterate(signal: AbortSignal): AsyncGenerator { + const abort = (): void => { + this.end(); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (true) { + while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame; + if (this.closed || signal.aborted) return; + await new Promise((resolve) => { + this.waiter = resolve; + }); + this.waiter = undefined; + } + } finally { + signal.removeEventListener("abort", abort); + } + } +} + +function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { + assertRemoteEventName(frame); + if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { + throw new TypeError( + `typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`, + ); + } +} + +function assertRemoteEventName(frame: { readonly event: unknown }): void { + if (typeof frame.event !== "string" || frame.event.length === 0) { + throw new TypeError("typert gateway: Remote event name must be a nonempty string"); + } +} + +function parseRemoteEventResultPayload( + payload: unknown, +): ReturnType { + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") + ) { + throw new Error( + "typert gateway: Remote event result requires exactly one plain-object args field", + ); + } + return parseRemoteEventResult(payload.args); +} + +function remoteRequest( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer?: PeerScope, +): InvokeRemoteRequest { + const segments = endpoint.split("/"); + if (segments.length !== 2 || segments[0] === "" || segments[1] === "") { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`); + } + const [namespace, method] = segments as [string, string]; + if ( + !isObject(payload) || + !isPlainObject(payload) || + Reflect.ownKeys(payload).length !== 1 || + !Object.hasOwn(payload, "args") || + !isObject(payload.args) || + !isPlainObject(payload.args) + ) { + throw new Error("Remote payload must contain exactly one plain-object args field"); + } + return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) }; +} + +/** + * The signal a method observes. A carrier that supplies an uplink fails the + * stream through `control` when an item is rejected, so that invocation joins + * `control` with the carrier signal; every other invocation keeps the carrier + * signal's identity. + */ +function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal { + const carrier = request.signal; + if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL; + if (carrier === undefined || carrier === control.signal) return control.signal; + return AbortSignal.any([carrier, control.signal]); +} + +function isIterable(value: unknown): value is Iterable | AsyncIterable { + return ( + isObject(value) && + (typeof Reflect.get(value, Symbol.iterator) === "function" || + typeof Reflect.get(value, Symbol.asyncIterator) === "function") + ); +} + +async function* cancellableStream( + source: Iterable | AsyncIterable, + endpoint: string, + invocation: GatewayInvocation, +): AsyncGenerator { + const { signal } = invocation; + const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator); + const syncFactory: unknown = Reflect.get(source, Symbol.iterator); + const iterator = + typeof asyncFactory === "function" + ? (Reflect.apply(asyncFactory, source, []) as AsyncIterator) + : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []); + let rejectAbort: ((error: unknown) => void) | undefined; + const onAbort = (): void => { + rejectAbort?.(streamAbortFailure(endpoint, signal.reason)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + while (true) { + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason); + // Reusing a pending cancellation promise retains every completed race. + const aborted = new Promise((_resolve, reject) => { + rejectAbort = reject; + }); + // next() can abort and throw synchronously before the race subscribes. + void aborted.catch(() => undefined); + const next = await Promise.race([Promise.resolve(iterator.next()), aborted]); + rejectAbort = undefined; + if (next.done === true) return; + yield next.value; + } + } finally { + rejectAbort = undefined; + signal.removeEventListener("abort", onAbort); + // The uplink closes first so a method blocked on `uplink.next()` unwinds + // before its iterator is asked to return. + await invocation.close(); + await iterator.return?.(); + } +} + +/** + * The failure a stream surfaces for its abort: a Remote failure used as the + * reason is the Gateway or carrier failing the stream itself (a rejected, + * overflowing, or misplaced uplink item); any other abort is a cancellation. + */ +function streamAbortFailure(endpoint: string, reason: unknown): unknown { + return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason; +} + +/** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ +function remoteCancelled(endpoint: string, cause: unknown): RemoteError<"gateway/cancelled"> { + return new RemoteError( + "gateway/cancelled", + `Remote invocation "${endpoint}" was aborted`, + {}, + { cause }, + ); +} + +/** + * The iterable `invocation.uplink()` returns. Each uplink item passes the + * descriptor codec, or the JSON-safety check when the descriptor declares no + * uplink, before delivery; a rejected item fails the whole logical stream. + * Iteration ends when the Client half-closes or the downlink finishes, and + * fails when the stream is cancelled, so a method blocked on the uplink always + * wakes. + */ +class UplinkDecoder implements AsyncIterable, AsyncIterator { + private readonly source: AsyncIterator; + private readonly interrupted = new Set>>(); + private readonly onAbort = (): void => { + const failure = streamAbortFailure(this.endpoint, this.signal.reason); + for (const read of this.interrupted) read.reject(failure); + }; + private closed = false; + + constructor( + uplink: AsyncIterable, + private readonly codec: TypertCodec, + private readonly endpoint: string, + private readonly signal: AbortSignal, + private readonly abort: (reason: unknown) => void, + ) { + this.source = uplink[Symbol.asyncIterator](); + signal.addEventListener("abort", this.onAbort, { once: true }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } + + async next(): Promise> { + // A cancelled stream reports the cancellation on every read, closed or not. + if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason); + if (this.closed) return UPLINK_DONE; + // Only pending reads belong to the decoder; finish() must wake all of them. + const interrupted = Promise.withResolvers>(); + this.interrupted.add(interrupted); + // The source can abort and throw synchronously before the race subscribes. + void interrupted.promise.catch(() => undefined); + let next: IteratorResult; + try { + next = await Promise.race([this.source.next(), interrupted.promise]); + } finally { + this.interrupted.delete(interrupted); + } + if (next.done === true) { + this.finish(); + return UPLINK_DONE; + } + let value: unknown; + try { + // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. + value = + next.value === undefined && this.codec.mode === "src-json" + ? undefined + : decode(this.codec, next.value, this.endpoint, "uplink"); + } catch (failure) { + // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. + this.abort(failure); + throw failure; + } + return { value, done: false }; + } + + /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ + return(): Promise> { + if (!this.closed) { + this.finish(); + // The carrier owns the source iterator; a generator blocked in next() + // completes this return once it yields, so it is not awaited here. + void Promise.resolve() + .then(() => this.source.return?.()) + .catch(() => undefined); + } + return Promise.resolve(UPLINK_DONE); + } + + private finish(): void { + this.closed = true; + this.signal.removeEventListener("abort", this.onAbort); + for (const read of this.interrupted) read.resolve(UPLINK_DONE); + } +} + +/** + * The context of one Remote call as the receiving method reads it through + * `this.ctx.invocation`. The uplink is decoded on first use and released when + * the call's downlink finishes. + */ +class GatewayInvocation implements RemoteInvocation { + private decoder: UplinkDecoder | undefined; + private taken = false; + + /** + * @param request - decoded endpoint and wire arguments. + * @param service - Cordis service key of the receiver. + * @param peer - Peer the call speaks for. + * @param signal - the signal the method observes. + * @param uplink - carrier items and the codec that decodes them. + */ + constructor( + readonly request: RemoteInvocation["request"], + readonly service: string, + readonly peer: PeerScope, + readonly signal: AbortSignal, + private readonly uplink_: UplinkSource, + ) {} + + uplink(): AsyncIterable { + if (this.taken) { + throw new Error( + `typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`, + ); + } + this.taken = true; + const { source, codec, endpoint, abort } = this.uplink_; + this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort); + // The descriptor codec decides what arrives; `In` is the caller's assertion. + return this.decoder as AsyncIterable; + } + + /** + * The downlink finished: release the uplink. Unread items are dropped, and a + * carrier iterable the method never took is returned so it stops producing; + * a later `uplink()` throws like a second one would. + * @returns settles once a taken uplink has closed. + */ + async close(): Promise { + if (this.decoder !== undefined) { + await this.decoder.return(); + return; + } + this.taken = true; + releaseUplink(this.uplink_.source); + } +} + +/** + * Return a carrier uplink nobody will read, so it drops later items instead of + * buffering them. The carrier owns the iterator and `return()` is not awaited: + * a generator blocked in `next()` completes it only once it yields. + * @param source - the carrier's uplink iterable. + */ +function releaseUplink(source: AsyncIterable): void { + void Promise.resolve() + .then(() => source[Symbol.asyncIterator]().return?.()) + .catch(() => undefined); +} + +function rpcFailure(error: unknown): ConnectionRpcResult { + const remote = remoteErrorOf(error); + if (remote !== undefined) { + return { + ok: false, + error: { code: remote.code, message: remote.message, details: remote.details }, + }; + } + return { + ok: false, + error: { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; +} + +function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { + return (rpcFailure(error) as Extract).error; +} + +function endpointOf(namespace: string, method: string): string { + return `${namespace}/${method}`; +} + +function validateBinding( + receiver: object, + serviceKey: string, + namespace: string, + endpoint: string, +): ResolvedBinding { + const original = originalOf(receiver); + const value: unknown = Reflect.get(original, "typertRemote"); + if (value === undefined) { + throw new TypertGatewayError( + "gateway/binding-invalid", + endpoint, + `Service ${JSON.stringify(serviceKey)} has no visible typertRemote binding`, + ); + } + return { + binding: readBinding(value, original, serviceKey, endpoint, namespace), + original, + }; +} + +function readBinding( + value: unknown, + original: object, + serviceKey: string, + endpoint: string, + namespace?: string, +): TypertGatewayBinding { + if ( + !isObject(value) || + Reflect.get(value, "service") !== original || + Reflect.get(value, "serviceKey") !== serviceKey || + typeof Reflect.get(value, "namespace") !== "string" || + (namespace !== undefined && Reflect.get(value, "namespace") !== namespace) + ) { + throw new TypertGatewayError( + "gateway/binding-invalid", + endpoint, + `Service ${JSON.stringify(serviceKey)} has an inconsistent typertRemote binding`, + ); + } + return value as TypertGatewayBinding; +} + +function originalOf(receiver: object): object { + const original: unknown = Reflect.get(receiver, symbols.original); + return isObject(original) ? original : receiver; +} + +function methodParameterNames( + service: object, + method: string, + endpoint: string, +): readonly string[] { + let prototype: object | null = Object.getPrototypeOf(service) as object | null; + let implementation: ((this: object, ...args: never[]) => unknown) | undefined; + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method); + if (descriptor !== undefined) { + if ("value" in descriptor && typeof descriptor.value === "function") { + implementation = descriptor.value as (this: object, ...args: never[]) => unknown; + } + break; + } + prototype = Object.getPrototypeOf(prototype) as object | null; + } + if (implementation === undefined) { + throw new TypertGatewayError( + "gateway/method-unavailable", + endpoint, + `Remote marker has no prototype method ${JSON.stringify(method)}`, + ); + } + const source = Function.prototype.toString.call(implementation); + const open = source.indexOf("("); + const close = source.indexOf(")", open + 1); + /* v8 ignore next -- standard public class-method syntax always contains a parenthesized parameter list. */ + if (open < 0 || close < 0) return invalidSignature(endpoint, method); + const body = source.slice(open + 1, close).trim(); + if (body.length === 0) return []; + const parts = body.split(",").map((part) => part.trim()); + const names = new Set(); + for (const part of parts) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) + return invalidSignature(endpoint, method); + names.add(part); + } + return [...names]; +} + +function invalidSignature(endpoint: string, method: string): never { + throw new TypertGatewayError( + "gateway/signature-invalid", + endpoint, + `SRC method ${JSON.stringify(method)} must use unique identifier parameters without destructuring, defaults, or rest`, + ); +} + +function assertExactArguments( + args: Readonly>, + descriptor: InvocationDescriptor, + endpoint: string, +): void { + if (!isPlainObject(args)) { + throw new TypertGatewayError( + "gateway/arguments-invalid", + endpoint, + "args must be a plain object", + ); + } + const expected = new Set(descriptor.parameters.map((parameter) => parameter.wire)); + if (descriptor.invocation.kind === "context") expected.add(descriptor.invocation.wire); + const actual = Reflect.ownKeys(args); + const extra = actual.filter((key) => typeof key !== "string" || !expected.has(key)); + // A JSON field may be omitted when the strict descriptor declares absence, + // and always under SRC: a weak descriptor reads parameter names from the + // JavaScript signature and cannot see which are optional, so LIB is where an + // omitted required argument is caught. Lookup ids are never omissible. + const acceptsMissing = new Set( + descriptor.parameters + .filter( + (parameter) => + parameter.source === "json" && + (parameter.acceptsUndefined === true || parameter.codec.mode === "src-json"), + ) + .map((parameter) => parameter.wire), + ); + const missing = [...expected].filter( + (key) => !Object.hasOwn(args, key) && !acceptsMissing.has(key), + ); + if (extra.length === 0 && missing.length === 0) return; + const clauses: string[] = []; + if (missing.length > 0) + clauses.push(`missing ${missing.map((key) => JSON.stringify(key)).join(", ")}`); + if (extra.length > 0) + clauses.push(`unexpected ${extra.map((key) => JSON.stringify(String(key))).join(", ")}`); + throw new TypertGatewayError( + "gateway/arguments-invalid", + endpoint, + `args fields do not match the descriptor: ${clauses.join("; ")}`, + ); +} + +function decode(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { + try { + if (codec.mode === "strict") { + value = codec.create().parse(value); + /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */ + if (value === undefined) return value; + } + assertJsonValue(value, new Set()); + return value; + } catch (cause) { + throw new TypertGatewayError( + "gateway/input-invalid", + endpoint, + `wire field ${JSON.stringify(field)} failed boundary validation`, + { cause, field }, + ); + } +} + +function assertJsonValue(value: unknown, ancestors: Set): void { + if (value === null || typeof value === "string" || typeof value === "boolean") return; + if (typeof value === "number") { + if (Number.isFinite(value)) return; + throw new TypeError("non-finite number is not JSON-safe"); + } + if (!isObject(value)) throw new TypeError(`${typeof value} is not JSON-safe`); + if (ancestors.has(value)) throw new TypeError("cyclic value is not JSON-safe"); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if ( + Object.getOwnPropertySymbols(value).length > 0 || + Object.keys(value).length !== value.length + ) { + throw new TypeError("sparse or decorated array is not JSON-safe"); + } + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError("sparse array is not JSON-safe"); + assertJsonValue(value[index], ancestors); + } + return; + } + if (!isPlainObject(value)) throw new TypeError("non-plain object is not JSON-safe"); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError("symbol property is not JSON-safe"); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + /* v8 ignore next -- ownKeys() just returned this key; only a hostile same-process Proxy can delete it between operations. */ + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("non-data property is not JSON-safe"); + } + assertJsonValue(descriptor.value, ancestors); + } + } finally { + ancestors.delete(value); + } +} + +function isPlainObject(value: object): value is Record { + if (Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value) as object | null; + return prototype === null || prototype === Object.prototype; +} + +function isObject(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +export default TypertGatewayService; diff --git a/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts b/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts new file mode 100644 index 000000000..98ff0b79f --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/remote-error-codes.ts @@ -0,0 +1,37 @@ +/** + * Gateway infrastructure failure codes merged into the shared Remote failure + * vocabulary. Face-neutral: the Host face and the Client face each import this + * module so both programs see the same map entries. + */ + +/** Wire details every Gateway infrastructure failure carries. */ +export interface TypertGatewayFaultDetails { + /** Canonical `/` endpoint. */ + readonly endpoint: string; + /** Affected wire field when the failure is field-specific. */ + readonly field?: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + "gateway/ambiguous-endpoint": TypertGatewayFaultDetails; + "gateway/arguments-invalid": TypertGatewayFaultDetails; + "gateway/binding-invalid": TypertGatewayFaultDetails; + "gateway/context-failed": TypertGatewayFaultDetails; + "gateway/context-not-found": TypertGatewayFaultDetails; + "gateway/context-unavailable": TypertGatewayFaultDetails; + "gateway/definition-unavailable": TypertGatewayFaultDetails; + "gateway/input-invalid": TypertGatewayFaultDetails; + "gateway/invocation-unavailable": TypertGatewayFaultDetails; + "gateway/lookup-failed": TypertGatewayFaultDetails; + "gateway/lookup-not-found": TypertGatewayFaultDetails; + "gateway/lookup-unavailable": TypertGatewayFaultDetails; + "gateway/method-unavailable": TypertGatewayFaultDetails; + "gateway/protocol": TypertGatewayFaultDetails; + "gateway/provider-mismatch": TypertGatewayFaultDetails; + "gateway/result-invalid": TypertGatewayFaultDetails; + "gateway/service-unavailable": TypertGatewayFaultDetails; + "gateway/signature-invalid": TypertGatewayFaultDetails; + "gateway/uplink-overflow": TypertGatewayFaultDetails; + } +} diff --git a/apps/web-ui/packages/api/gateway/src/stream-protocol.ts b/apps/web-ui/packages/api/gateway/src/stream-protocol.ts new file mode 100644 index 000000000..f2f912fdb --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/stream-protocol.ts @@ -0,0 +1,399 @@ +/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ + +import type { Branded } from "@deepseek-ai/dsh-brand"; +import { isRemoteJsonValue } from "@deepseek-ai/dsh-typert-protocol"; + +/** Exact WebSocket route carrying every Typert Remote stream. */ +export const REMOTE_STREAM_MUX_PATH = "/api/remote.mux"; + +/** Gateway-internal logical stream carrying application-selected Cordis events. */ +export const REMOTE_EVENT_STREAM_ENDPOINT = "$events"; + +/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */ +export const REMOTE_EVENT_RESULT_ENDPOINT = "$events/result"; + +/** Empty standard Remote payload used to open the forwarded-event stream. */ +export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const; + +/** Discriminator for the first item proving the Host event source is ready. */ +export const REMOTE_EVENT_STREAM_READY = { type: "ready" } as const; + +/** Opaque identity for one active Client Remote Event generation. */ +export type RemoteEventClientId = Branded<"RemoteEventClientId">; + +/** Opaque correlation id for one pending Host-to-Client Remote Event. */ +export type RemoteEventId = Branded<"RemoteEventId">; + +/** Stable Host facts published with every established Client event generation. */ +export interface RemoteEventHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string; +} + +/** Opening item that binds later HTTP results to this active event stream. */ +export interface RemoteEventReadyFrame { + readonly type: "ready"; + readonly clientId: RemoteEventClientId; + /** Stable Host facts attached to this connection generation. */ + readonly host: RemoteEventHostInfo; +} + +/** Opaque Agent identity carried by one scoped Remote Event. */ +export type RemoteEventAgentId = Branded<"RemoteEventAgentId">; + +/** One Host notification delivered to a Client generation. */ +export interface RemoteEventEmitFrame { + readonly type: "emit"; + readonly event: string; + readonly args: readonly unknown[]; +} + +/** One pending Agent-scoped waterfall delivered to a Client generation. */ +export interface RemoteEventInvocationFrame { + readonly type: "waterfall"; + readonly event: string; + readonly eventId: RemoteEventId; + readonly agentId: RemoteEventAgentId; + readonly request: Readonly>; +} + +/** Cancellation of a pending waterfall previously delivered under the same id. */ +export interface RemoteEventCancellationFrame { + readonly type: "cancel"; + readonly eventId: RemoteEventId; +} + +/** Every item carried by the Gateway-internal forwarded-event stream. */ +export type RemoteEventDownlinkFrame = + | RemoteEventReadyFrame + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame; + +/** JSON request fields plus the Host cancellation lifetime removed for transport. */ +export interface ProjectedRemoteEventRequest { + readonly request: Readonly>; + readonly signal?: AbortSignal; +} + +/** Error fields retained when a Client listener rejects a Host waterfall. */ +export interface RemoteEventRejection { + readonly name: string; + readonly message: string; + readonly code?: string; + readonly details?: unknown; +} + +/** Client response to one scoped Remote Event delivery. */ +export interface RemoteEventResult { + readonly clientId: RemoteEventClientId; + readonly eventId: RemoteEventId; + readonly outcome: + | { readonly kind: "next" } + | { readonly kind: "result"; readonly value?: unknown } + | { readonly kind: "rejected"; readonly error: RemoteEventRejection }; +} + +/** + * Parse one result sent through the Client's `$events/result` HTTP RPC. + * @param value - untrusted result payload. + * @returns validated event correlation and outcome fields. + */ +export function parseRemoteEventResult(value: unknown): RemoteEventResult { + if ( + !isRecord(value) || + !exactKeys(value, ["clientId", "eventId", "outcome"]) || + !isRemoteEventClientId(value.clientId) || + !isRemoteEventId(value.eventId) || + !isRecord(value.outcome) + ) { + throw new Error("api gateway: invalid Remote event result"); + } + const outcome = value.outcome; + if (outcome.kind === "next" && exactKeys(outcome, ["kind"])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: "next" }, + }; + } + if ( + outcome.kind === "result" && + (exactKeys(outcome, ["kind"]) || exactKeys(outcome, ["kind", "value"])) && + (!Object.hasOwn(outcome, "value") || isRemoteJsonValue(outcome.value)) + ) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: Object.hasOwn(outcome, "value") + ? { kind: "result", value: outcome.value } + : { kind: "result" }, + }; + } + if (outcome.kind === "rejected" && exactKeys(outcome, ["kind", "error"])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: "rejected", error: parseRemoteEventRejection(outcome.error) }, + }; + } + throw new Error("api gateway: invalid Remote event result"); +} + +/** + * Remove the direct Agent and cancellation fields from one waterfall request. + * @param value - request object before the waterfall's `next` callback. + * @param subject - Agent used by the Cordis scope carrier. + * @returns JSON-safe request fields and the optional Host cancellation signal. + */ +export function projectRemoteEventRequest( + value: unknown, + subject: object, +): ProjectedRemoteEventRequest { + if (!isPlainRecord(value) || !Object.hasOwn(value, "agent") || value.agent !== subject) { + throw new TypeError("api gateway: Remote event request must carry its scoped Agent directly"); + } + const signal = value.signal; + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError("api gateway: Remote event request signal must be an AbortSignal"); + } + const request: Record = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (key === "agent" || key === "signal") continue; + const descriptor = + typeof key === "string" ? Object.getOwnPropertyDescriptor(value, key) : undefined; + if (typeof key !== "string" || descriptor?.enumerable !== true) { + throw new TypeError("api gateway: Remote event request has a non-JSON property"); + } + request[key] = Reflect.get(value, key); + } + if (!isRemoteJsonValue(request)) { + throw new TypeError("api gateway: Remote event request is not lossless JSON data"); + } + return { + request, + ...(signal === undefined ? {} : { signal }), + }; +} + +/** + * Project an arbitrary rejection to stable, JSON-safe error fields. + * @param reason - value thrown or rejected by a Client listener. + * @returns wire-safe rejection fields. + */ +export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection { + const record = typeof reason === "object" && reason !== null ? reason : undefined; + const name = stringProperty(record, "name") ?? "Error"; + const message = stringProperty(record, "message") ?? String(reason); + const code = stringProperty(record, "code"); + const details = record === undefined ? undefined : (Reflect.get(record, "details") as unknown); + return { + name, + message, + ...(code === undefined ? {} : { code }), + ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }), + }; +} + +/** + * Recreate a Client rejection for the Host continuation. + * @param rejection - validated wire-safe error fields. + * @returns an Error preserving the remote name, code, and JSON-safe details. + */ +export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error { + const error = new Error(rejection.message) as Error & { code?: string; details?: unknown }; + error.name = rejection.name; + if (rejection.code !== undefined) error.code = rejection.code; + if (rejection.details !== undefined) error.details = rejection.details; + return error; +} + +/** + * Recognize a non-empty Remote Event correlation id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value is a valid Remote Event id. + */ +export function isRemoteEventId(value: unknown): value is RemoteEventId { + return typeof value === "string" && value.length > 0; +} + +/** + * Recognize a non-empty Remote Event Client id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value identifies one event-stream generation. + */ +export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId { + return typeof value === "string" && value.length > 0; +} + +/** + * Recognize the direct Agent identity used by a scoped Remote Event. + * @param value - untrusted wire value. + * @returns whether the value is a non-empty Agent identity. + */ +export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId { + return typeof value === "string" && value.length > 0; +} + +/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */ +export type RemoteStreamClientMessage = + | { + readonly type: "open"; + readonly streamId: string; + readonly endpoint: string; + readonly payload: unknown; + } + | { readonly type: "item"; readonly streamId: string; readonly value?: unknown } + | { readonly type: "end"; readonly streamId: string } + | { readonly type: "cancel"; readonly streamId: string }; + +/** Carrier-safe failure delivered by the Host. */ +export interface RemoteStreamFailure { + readonly code: string; + readonly message: string; + readonly details: object; +} + +/** One logical stream frame sent from the Host. */ +export type RemoteStreamServerMessage = + | { readonly type: "item"; readonly streamId: string; readonly value?: unknown } + | { readonly type: "error"; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: "end"; readonly streamId: string }; + +/** + * Parse and validate one browser-to-Host text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream request. + */ +export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { + return parseMessage(text, (value) => { + if ( + (value.type === "cancel" || value.type === "end") && + exactKeys(value, ["type", "streamId"]) && + validId(value.streamId) + ) { + return value as RemoteStreamClientMessage; + } + if ( + value.type === "item" && + (exactKeys(value, ["type", "streamId"]) || exactKeys(value, ["type", "streamId", "value"])) && + validId(value.streamId) && + (!Object.hasOwn(value, "value") || isRemoteJsonValue(value.value)) + ) { + return value as RemoteStreamClientMessage; + } + if ( + value.type === "open" && + exactKeys(value, ["type", "streamId", "endpoint", "payload"]) && + validId(value.streamId) && + typeof value.endpoint === "string" && + value.endpoint.length > 0 + ) { + return value as RemoteStreamClientMessage; + } + throw new Error("api gateway: invalid Remote stream client message"); + }); +} + +/** + * Parse and validate one Host-to-browser text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream frame. + */ +export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage { + return parseMessage(text, (value) => { + if ( + value.type === "item" && + (exactKeys(value, ["type", "streamId"]) || exactKeys(value, ["type", "streamId", "value"])) && + validId(value.streamId) + ) { + return value as RemoteStreamServerMessage; + } + if (value.type === "end" && exactKeys(value, ["type", "streamId"]) && validId(value.streamId)) { + return value as RemoteStreamServerMessage; + } + if ( + value.type === "error" && + exactKeys(value, ["type", "streamId", "error"]) && + validId(value.streamId) && + isRecord(value.error) && + exactKeys(value.error, ["code", "message", "details"]) && + typeof value.error.code === "string" && + typeof value.error.message === "string" && + isRecord(value.error.details) + ) { + return value as RemoteStreamServerMessage; + } + throw new Error("api gateway: invalid Remote stream server message"); + }); +} + +function parseMessage(text: string, validate: (value: Record) => T): T { + let decoded: unknown; + try { + decoded = JSON.parse(text) as unknown; + } catch (cause) { + throw new Error("api gateway: Remote stream message is not JSON", { cause }); + } + if (!isRecord(decoded)) throw new Error("api gateway: Remote stream message must be an object"); + return validate(decoded); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false; + const prototype: unknown = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value); + return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function validId(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function parseRemoteEventRejection(value: unknown): RemoteEventRejection { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ["name", "message"], ["code", "details"]) || + typeof value.name !== "string" || + value.name.length === 0 || + typeof value.message !== "string" || + (Object.hasOwn(value, "code") && typeof value.code !== "string") || + (Object.hasOwn(value, "details") && !isRemoteJsonValue(value.details)) + ) { + throw new Error("api gateway: invalid Remote event rejection"); + } + return { + name: value.name, + message: value.message, + ...(typeof value.code === "string" ? { code: value.code } : {}), + ...(Object.hasOwn(value, "details") ? { details: value.details } : {}), + }; +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Reflect.ownKeys(value); + return ( + required.every((key) => Object.hasOwn(value, key)) && + keys.every( + (key) => typeof key === "string" && (required.includes(key) || optional.includes(key)), + ) + ); +} + +function stringProperty(value: object | undefined, key: string): string | undefined { + if (value === undefined) return undefined; + const candidate: unknown = Reflect.get(value, key); + return typeof candidate === "string" ? candidate : undefined; +} diff --git a/apps/web-ui/packages/api/gateway/src/stream-server.ts b/apps/web-ui/packages/api/gateway/src/stream-server.ts new file mode 100644 index 000000000..aa3d1bb46 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/stream-server.ts @@ -0,0 +1,480 @@ +/** Host WebSocket owner for multiplexed Typert Remote streams. */ + +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { RemoteError, remoteErrorOf, type PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import WebSocket, { WebSocketServer, type RawData } from "ws"; +import { + parseRemoteStreamClientMessage, + type RemoteStreamClientMessage, + type RemoteStreamFailure, + type RemoteStreamServerMessage, +} from "./stream-protocol.ts"; + +/** + * Open one validated Remote stream for a decoded wire request on behalf of one + * Peer. `uplink` carries the Client's items; `control` cancels the logical + * stream, and aborting it with a Remote failure as the reason delivers that + * failure to the Client. + */ +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise>; + +/** The opener one socket uses: its Peer is fixed at upgrade time. */ +type BoundStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + control: AbortController, +) => Promise>; + +/** Convert an invocation or carrier failure to a stable wire value. */ +export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure; + +const MAX_MISSED_HEARTBEATS = 2; + +/** Own the no-server WebSocket acceptor and every active logical stream. */ +export class RemoteStreamMuxServer { + private readonly server = new WebSocketServer({ noServer: true }); + private readonly connections = new Set>(); + private readonly missedHeartbeats = new WeakMap(); + private heartbeatTimer: NodeJS.Timeout | undefined; + + /** + * @param open - Gateway stream dispatcher. + * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. + * @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails. + */ + constructor( + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, + private readonly streamInboxBytes: number, + ) {} + + /** + * Upgrade one admitted request and begin serving its logical streams. Every + * stream the socket opens speaks for the Peer admitted at upgrade, and the + * socket closes when that Peer's scope is disposed. + * @param req - authenticated HTTP upgrade request. + * @param socket - carrier socket transferred to the WebSocket server. + * @param head - bytes already read after the HTTP upgrade headers. + * @param peer - Peer the upgrade was admitted as. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const release = bindPeer(websocket, peer); + if (release === undefined) return; + this.missedHeartbeats.set(websocket, 0); + websocket.on("pong", () => { + this.missedHeartbeats.set(websocket, 0); + }); + this.startHeartbeat(); + const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => + this.open(endpoint, payload, uplink, peer, control); + const connection = new RemoteStreamMuxConnection( + websocket, + bound, + this.failure, + this.streamInboxBytes, + ); + const done = connection.run(); + this.connections.add(done); + void done.then(() => { + this.connections.delete(done); + void release(); + }); + }); + } + + /** Terminate all sockets and wait until every iterator has returned. */ + async close(): Promise { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + for (const socket of this.server.clients) socket.terminate(); + const closed = Promise.withResolvers(); + this.server.close((error) => { + if (error === undefined) closed.resolve(); + else closed.reject(error); + }); + await closed.promise; + await Promise.all(this.connections); + } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return; + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState !== WebSocket.OPEN) continue; + const missed = this.missedHeartbeats.get(socket) as number; + if (missed >= MAX_MISSED_HEARTBEATS) { + setImmediate(() => { + if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) { + socket.terminate(); + } + }); + continue; + } + this.missedHeartbeats.set(socket, missed + 1); + socket.ping(); + } + }, this.heartbeatIntervalMs); + this.heartbeatTimer.unref(); + } +} + +interface ActiveStream { + readonly abort: AbortController; + readonly inbox: UplinkInbox; + /** Cancel the logical stream and end any Host read still waiting on its uplink. */ + readonly stop: (reason: Error) => void; + done: Promise; +} + +class RemoteStreamMuxConnection { + private readonly streams = new Map(); + private writes = Promise.resolve(); + + constructor( + private readonly socket: WebSocket, + private readonly open: BoundStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + private readonly streamInboxBytes: number, + ) {} + + async run(): Promise { + const closed = new Promise((resolve) => { + this.socket.once("close", resolve); + this.socket.once("error", () => { + this.socket.terminate(); + }); + this.socket.on("message", (data, isBinary) => { + if (isBinary) { + this.socket.close(1003, "text messages required"); + return; + } + try { + this.receive(rawText(data)); + } catch { + this.socket.close(1008, "invalid Remote stream request"); + } + }); + }); + await closed; + const active = [...this.streams.values()]; + for (const stream of active) stream.stop(new Error("Remote stream socket closed")); + await Promise.all(active.map((stream) => stream.done)); + } + + /** + * Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection + * no longer owns are dropped: a finished stream leaves the table while the + * Client's in-flight frames are still arriving. A duplicate `open` is the one + * protocol violation that closes the socket. + */ + private receive(text: string): void { + const message = parseRemoteStreamClientMessage(text); + switch (message.type) { + case "open": { + this.openStream(message); + return; + } + case "item": { + this.streams + .get(message.streamId) + ?.inbox.push(message.value, Buffer.byteLength(text, "utf8")); + return; + } + case "end": { + this.streams.get(message.streamId)?.inbox.end(); + return; + } + case "cancel": { + this.streams.get(message.streamId)?.stop(new Error("Remote stream cancelled")); + return; + } + /* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */ + default: { + const unknown: never = message; + throw new Error( + `api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`, + ); + } + } + } + + private openStream(message: Extract): void { + if (this.streams.has(message.streamId)) { + throw new Error( + `api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`, + ); + } + const abort = new AbortController(); + // Created before the opener resolves so items the Client sends right + // after `open` wait in the inbox instead of being lost. + const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { + abort.abort(error); + }); + const active: ActiveStream = { + abort, + inbox, + stop: (reason) => { + abort.abort(reason); + inbox.fail(reason); + }, + done: Promise.resolve(), + }; + this.streams.set(message.streamId, active); + const done = this.pump(message.streamId, message.endpoint, message.payload, active); + active.done = done; + const remove = (): void => { + this.streams.delete(message.streamId); + }; + void done.then(remove, remove); + } + + private async pump( + streamId: string, + endpoint: string, + payload: unknown, + active: ActiveStream, + ): Promise { + let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown }; + try { + const source = await this.open(endpoint, payload, active.inbox, active.abort); + for await (const value of source) { + await this.send({ type: "item", streamId, value }); + } + outcome = { failed: false }; + } catch (error) { + outcome = { failed: true, error }; + } + // The downlink has settled; later uplink frames cannot change the outcome. + active.inbox.fail(new Error("Remote stream ended")); + if (active.abort.signal.aborted) { + // A Remote failure as the abort reason is the Gateway or this mux failing + // the stream (a rejected or overflowing uplink item, or an item after + // end); the Client is still waiting for that terminal frame. Any other + // abort is a cancellation. + const reason: unknown = active.abort.signal.reason; + if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason); + return; + } + if (outcome.failed) { + await this.sendFailure(streamId, outcome.error); + return; + } + try { + await this.send({ type: "end", streamId }); + } catch (error) { + await this.sendFailure(streamId, error); + } + } + + private async sendFailure(streamId: string, error: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) return; + try { + await this.send({ type: "error", streamId, error: this.failure(error) }); + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, "Remote stream failure could not be delivered"); + } + } + + private send(message: RemoteStreamServerMessage): Promise { + let text: string; + try { + text = JSON.stringify(message); + } catch (cause) { + return Promise.reject( + new Error("api gateway: Remote stream item is not JSON serializable", { cause }), + ); + } + const delivery = this.writes.then( + () => + new Promise((resolve, reject) => { + if (this.socket.readyState !== WebSocket.OPEN) { + reject(new Error("api gateway: Remote stream socket is closed")); + return; + } + this.socket.send(text, (error) => { + if (error) reject(error); + else resolve(); + }); + }), + ); + this.writes = delivery.catch(() => undefined); + return delivery; + } +} + +interface UplinkEntry { + readonly value: unknown; + readonly bytes: number; +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true }; + +/** + * Bounded single-consumer uplink queue of one logical stream, the source the + * Host method reads through `invocation.uplink()`. Buffered frame bytes are + * capped: overflow, and an item after the Client's `end`, fail the queue and + * report a Remote failure that the connection uses to fail the logical stream. + */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + private readonly queue = new Deque(); + private bytes = 0; + private ended = false; + private closed = false; + private taken = false; + private failure: Error | undefined; + private wake: (() => void) | undefined; + + constructor( + private readonly maxBytes: number, + private readonly endpoint: string, + private readonly onViolation: ( + error: RemoteError<"gateway/protocol" | "gateway/uplink-overflow">, + ) => void, + ) {} + + push(value: unknown, frameBytes: number): void { + if (this.failure !== undefined || this.closed) return; + if (this.ended) { + this.violate( + new RemoteError("gateway/protocol", "api gateway: Remote stream uplink item after end", { + endpoint: this.endpoint, + }), + ); + return; + } + if (this.bytes + frameBytes > this.maxBytes) { + this.violate( + new RemoteError( + "gateway/uplink-overflow", + `api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`, + { endpoint: this.endpoint }, + ), + ); + return; + } + this.queue.pushBack({ value, bytes: frameBytes }); + this.bytes += frameBytes; + this.signal(); + } + + /** Client half-close; idempotent. */ + end(): void { + if (this.ended) return; + this.ended = true; + this.signal(); + } + + /** End the consumer's next read with `error`; idempotent, drops buffered items. */ + fail(error: Error): void { + if (this.failure !== undefined) return; + this.failure = error; + this.queue.clear(); + this.bytes = 0; + this.signal(); + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.taken) + throw new Error("api gateway: Remote stream uplink inbox already has a consumer"); + this.taken = true; + return this; + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE; + const entry = this.queue.popFront(); + if (entry !== undefined) { + this.bytes -= entry.bytes; + return { value: entry.value, done: false }; + } + if (this.failure !== undefined) throw this.failure; + if (this.ended) return UPLINK_DONE; + if (this.wake !== undefined) + throw new Error("api gateway: Remote stream uplink inbox has one pending read"); + await new Promise((resolve) => { + this.wake = resolve; + }); + } + } + + /** Consumer stopped reading: later items are dropped, a pending read ends. */ + return(): Promise> { + this.closed = true; + this.queue.clear(); + this.bytes = 0; + this.signal(); + return Promise.resolve(UPLINK_DONE); + } + + private violate(error: RemoteError<"gateway/protocol" | "gateway/uplink-overflow">): void { + this.fail(error); + this.onViolation(error); + } + + private signal(): void { + const wake = this.wake; + this.wake = undefined; + wake?.(); + } +} + +/** + * Close the socket when the Peer's scope is disposed. A scope that is already + * disposed leaves no Peer for the socket to speak for, so the socket closes now. + * @returns the registration's disposer, or `undefined` when the socket was closed. + */ +function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined { + try { + return peer.ctx.effect( + () => () => { + websocket.close(1001, "peer left"); + }, + "api-gateway: Remote stream socket bound to its Peer", + ); + } catch { + websocket.close(1001, "peer left"); + return undefined; + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + return Buffer.from(data).toString("utf8"); +} + +/** + * Reject an upgrade without transferring socket ownership to ws. + * @param socket - carrier socket that receives the HTTP rejection. + * @param status - authentication or browser-trust rejection status. + */ +export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void { + const reason = status === 401 ? "Unauthorized" : "Forbidden"; + const body = reason.toLowerCase(); + socket.end( + [ + `HTTP/1.1 ${String(status)} ${reason}`, + "Connection: close", + "Content-Type: text/plain; charset=utf-8", + `Content-Length: ${String(Buffer.byteLength(body))}`, + "", + body, + ].join("\r\n"), + ); +} diff --git a/apps/web-ui/packages/api/gateway/src/types.ts b/apps/web-ui/packages/api/gateway/src/types.ts new file mode 100644 index 000000000..7077b7a8d --- /dev/null +++ b/apps/web-ui/packages/api/gateway/src/types.ts @@ -0,0 +1,179 @@ +/** + * Carrier-independent Typert Gateway request, service, and error contracts. + * @module @deepseek-ai/dsh-api-gateway/types + */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteEventHostInfo } from "./stream-protocol.ts"; + +/** One Remote method request after a carrier has decoded its envelope. */ +export interface InvokeRemoteRequest { + /** Remote namespace selected by the generated descriptor. */ + readonly namespace: string; + /** Exported Service method name. */ + readonly method: string; + /** Named wire values; fields must exactly match the descriptor. */ + readonly args: Readonly>; + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable; + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope; + /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ + readonly signal?: AbortSignal; +} + +/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */ +export interface TypertRemoteEventFrame { + /** Original Host Cordis event name. */ + readonly event: string; + /** Original event argument list after the owner validates it for JSON transport. */ + readonly args: readonly unknown[]; +} + +/** Live Host values used to project one scoped Remote Event. */ +export interface TypertRemoteEventContext { + /** Live Agent Context that owns cancellation of the forwarded waterfall. */ + readonly value: Context; + /** Agent object carried directly by the waterfall request. */ + readonly subject: object; + /** Agent identity read directly from the scoped event subject. */ + readonly agentId: string; +} + +/** Result returned from a Client waterfall, or delegation back to the Host chain. */ +export type TypertRemoteEventOutcome = + { readonly kind: "result"; readonly value: unknown } | { readonly kind: "next" }; + +/** + * One scoped waterfall invocation yielded by the application event source. + * The Gateway alone assigns transport ids and resolves the continuation after + * a Client result or explicit delegation. + */ +export interface TypertRemoteEventInvocation { + /** Original Host Cordis event name. */ + readonly event: string; + /** Sole request argument before the waterfall's `next()` callback. */ + readonly request: object; + readonly context: TypertRemoteEventContext; + /** Resume the source's Cordis listener with a Client result or `next()`. */ + readonly resolve: (outcome: TypertRemoteEventOutcome) => void; + /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */ + readonly reject: (reason: unknown) => void; +} + +/** Notification or scoped waterfall accepted from the sole Remote Event source. */ +export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation; + +/** + * Open the application-selected event stream for one Client carrier. The + * factory must attach all incremental Host listeners before it returns; the + * Gateway publishes its readiness item immediately afterward. + * @param signal - cancellation shared with the Client stream and registration. + * @returns the long-lived stream of notifications and scoped waterfall invocations. + */ +export type TypertRemoteEventSource = ( + signal: AbortSignal, +) => AsyncIterable; + +/** Carrier-facing access to decoded Remote streams and their stable failures. */ +export interface TypertGatewayWireStream { + /** + * Open one logical stream from its wire endpoint and payload. + * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. + * @param payload - decoded carrier payload. + * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator + * as soon as it opens, so the carrier drops those items instead of buffering them. + * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. + * @param signal - logical-stream cancellation. + * @returns validated stream values. + */ + readonly open: ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, + signal: AbortSignal, + ) => Promise>; + + /** + * Convert a stream failure to the carrier-safe Remote failure fields. + * @param error - failure raised while opening or consuming a stream. + * @returns stable code, message, and details for the Client. + */ + readonly failure: (error: unknown) => { + readonly code: string; + readonly message: string; + readonly details: object; + }; +} + +/** Stable infrastructure and boundary failures emitted before or after business execution. */ +export type TypertGatewayErrorCode = + | "gateway/ambiguous-endpoint" + | "gateway/arguments-invalid" + | "gateway/binding-invalid" + | "gateway/context-failed" + | "gateway/context-not-found" + | "gateway/context-unavailable" + | "gateway/definition-unavailable" + | "gateway/input-invalid" + | "gateway/invocation-unavailable" + | "gateway/lookup-failed" + | "gateway/lookup-not-found" + | "gateway/lookup-unavailable" + | "gateway/method-unavailable" + | "gateway/protocol" + | "gateway/provider-mismatch" + | "gateway/result-invalid" + | "gateway/service-unavailable" + | "gateway/signature-invalid" + | "gateway/uplink-overflow"; + +/** Host dispatcher consumed by Connection adapters. */ +export interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream; + + /** + * Check for an active Client event stream. + * @returns whether a stream is open and has not been cancelled. + */ + hasLiveClient(): boolean; + + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise; + + /** + * Invoke one live Remote method without assuming a carrier or response envelope. + * @param request - decoded endpoint and named wire arguments. + * @returns the business result without output decoding. + * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. + */ + invoke(request: InvokeRemoteRequest): Promise; + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise>; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host dispatcher for Typert Remote calls. */ + typertGateway: TypertGateway; + } +} diff --git a/apps/web-ui/packages/api/gateway/tsdown.config.ts b/apps/web-ui/packages/api/gateway/tsdown.config.ts new file mode 100644 index 000000000..f5bf13e85 --- /dev/null +++ b/apps/web-ui/packages/api/gateway/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-gateway", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/api/job-controller/generated/remote.d.ts b/apps/web-ui/packages/api/job-controller/generated/remote.d.ts new file mode 100644 index 000000000..54d1da8f2 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/generated/remote.d.ts @@ -0,0 +1,27 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { JobFollowFrame, JobFollowRequest, JobKillRequest, JobKillValue, JobListFrame, JobListRequest } from '@deepseek-ai/dsh-api-job-controller/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6a6f62 { + follow: (request: JobFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + kill: (request: JobKillRequest) => Promise> + list: (request: JobListRequest, signal?: AbortSignal) => RemoteStreamHandle + } + interface TypertRemoteMap { + 'job/follow': (request: JobFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + 'job/kill': (request: JobKillRequest) => Promise> + 'job/list': (request: JobListRequest, signal?: AbortSignal) => RemoteStreamHandle + } + interface TypertRemoteNamespaceMap { + 'job': TypertRemoteNamespace$6a6f62 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/job-controller/generated/remote.js b/apps/web-ui/packages/api/job-controller/generated/remote.js new file mode 100644 index 000000000..9e9ad3067 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/generated/remote.js @@ -0,0 +1,181 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'jobId': z.intersection(z.string(), z.unknown()).readonly(), + 'from': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_job_controller_job_follow_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_follow_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("opened").readonly(), + 'job': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +}).readonly(), + 'from': z.number().readonly(), +}), z.object({ + 'type': z.literal("output").readonly(), + 'chunks': z.array(z.object({ + 'at': z.number().readonly(), + 'text': z.string().readonly(), + 'channel': z.union([z.literal("stdout"), z.literal("stderr"), z.literal("log")]).readonly().optional(), + 'gapBefore': z.literal(true).readonly().optional(), +})).readonly(), + 'next': z.number().readonly(), + 'lossy': z.literal(true).readonly().optional(), +}), z.object({ + 'type': z.literal("status").readonly(), + 'job': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'jobId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_kill_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_kill_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_kill_result$schema$value ??= z.object({ + 'outcome': z.union([z.literal("requested"), z.literal("already-finished")]).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_job_controller_job_list_result$schema$value +const _deepseek_ai_dsh_api_job_controller_job_list_result$schema = () => (_deepseek_ai_dsh_api_job_controller_job_list_result$schema$value ??= z.object({ + 'type': z.literal("rows").readonly(), + 'jobs': z.array(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'kind': z.string().readonly(), + 'label': z.string().readonly(), + 'owner': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'outputLimitBytes': z.number().readonly().optional(), + 'status': z.union([z.literal("failed"), z.literal("running"), z.literal("stopping"), z.literal("completed"), z.literal("killed")]).readonly(), + 'progress': z.string().readonly().optional(), + 'detail': z.string().readonly().optional(), + 'startedAt': z.number().readonly(), + 'finishedAt': z.number().readonly().optional(), + 'output': z.object({ + 'total': z.number().readonly(), + 'earliest': z.number().readonly(), + 'spillPaths': z.array(z.string()).readonly().optional(), +}).readonly(), +})).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-job-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-job-controller#job/follow', + service: 'jobController', + namespace: 'job', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobFollowRequest', + create: _deepseek_ai_dsh_api_job_controller_job_follow_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobFollowFrame', + create: _deepseek_ai_dsh_api_job_controller_job_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":91,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-job-controller#job/kill', + service: 'jobController', + namespace: 'job', + method: 'kill', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobKillRequest', + create: _deepseek_ai_dsh_api_job_controller_job_kill_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobKillValue', + create: _deepseek_ai_dsh_api_job_controller_job_kill_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":110,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-job-controller#job/list', + service: 'jobController', + namespace: 'job', + method: 'list', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobListRequest', + create: _deepseek_ai_dsh_api_job_controller_job_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-job-controller/types#JobListFrame', + create: _deepseek_ai_dsh_api_job_controller_job_list_result$schema, + }, + sourceLocation: {"file":"packages/api/job-controller/src/index.ts","line":76,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/job-controller/package.json b/apps/web-ui/packages/api/job-controller/package.json new file mode 100644 index 000000000..4ec9d5d9d --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/index.ts b/apps/web-ui/packages/api/job-controller/src/client/index.ts new file mode 100644 index 000000000..3d790414c --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,47 @@ +/** + * Job Controller client half: installs `ctx.jobs` (rosters, observations, and + * the human kill) over the generated `job` Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-job-controller/remote"; +import { ClientJobsModel } from "./model.ts"; +import { ClientJobs } from "./service.ts"; + +export type { JobsSnapshot, ObservedJob } from "./model.ts"; +// The `ctx.jobs` contract. Its module also carries the Context augmentation +// that declares `ctx.jobs`, which reaches consumers only through this export: +// declaration emit drops the value import above. +export type { IJobs } from "./service.ts"; +export type { + JobChunk, + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, + JobView, +} from "../types.ts"; + +/** Required Client Remote services. */ +export const inject = ["remote", "remote.job"]; + +/** + * Install the client jobs service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + // Read the namespace now, not inside `open`: see the module JSDoc. + const { remote } = ctx; + const { job } = remote; + new ClientJobs( + ctx, + { $stream: (options) => remote.$stream(options), job }, + new ClientJobsModel(), + ); +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/model.ts b/apps/web-ui/packages/api/job-controller/src/client/model.ts new file mode 100644 index 000000000..3a9f4f627 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,223 @@ +/** + * React-free client job state: the roster each watched session can see, fed + * by `job.list` frames, and per-job accumulated output views fed by + * `job.follow` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobView } from "@deepseek-ai/dsh-jobs/view"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { JobFollowFrame } from "../types.ts"; + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024; + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId; + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string; + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean; + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean; + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string; +} + +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly>; + /** Live observation state keyed by job id. */ + readonly observed: Readonly>; +} + +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobsSnapshot; + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob; + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined; +} + +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map(); + private readonly observedStates = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} }; + private snapshotDirty = false; + + getSnapshot(): JobsSnapshot { + if (this.snapshotDirty) { + const rows: Record = {}; + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs; + const observed: Record = {}; + for (const [id, state] of this.observedStates) observed[id] = state.view; + this.snapshotCache = { rows, observed }; + this.snapshotDirty = false; + } + return this.snapshotCache; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId); + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return; + } else { + this.rowsBySession.set(key, jobs); + } + this.changed(); + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return; + this.changed(); + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor; + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)); + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === "") && frame.from > 0; + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? "", + gapBefore: + (existing?.view.gapBefore ?? false) || + frame.from < frame.job.output.earliest || + freshPastHead, + streaming: true, + }; + this.observedStates.set(String(id), { view, cursor: frame.from }); + this.changed(); + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)); + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return; + let text = state.view.text + frame.chunks.map((chunk) => chunk.text).join(""); + let gapBefore = + state.view.gapBefore || + frame.lossy === true || + frame.chunks.some((chunk) => chunk.gapBefore === true); + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT; + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut); + if (unit >= 0xdc00 && unit <= 0xdfff) cut += 1; + text = text.slice(cut); + gapBefore = true; + } + state.view = { ...state.view, text, gapBefore }; + state.cursor = frame.next; + this.changed(); + } + + /** + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. + * @param id - observed job. + */ + observeSettled(id: JobId): void { + const state = this.observedStates.get(String(id)); + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return; + state.view = { ...state.view, streaming: false }; + this.changed(); + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)); + if (state === undefined) { + // A failure before the anchor — a rejected request, a job gone between + // the click and the open — still owes the panel its notice; a later + // successful anchor replaces this view and resumes from no cursor. + this.observedStates.set(String(id), { + view: { jobId: id, text: "", gapBefore: false, streaming: false, error: String(error) }, + cursor: undefined, + }); + this.changed(); + return; + } + state.view = { ...state.view, streaming: false, error: String(error) }; + this.changed(); + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return; + this.changed(); + } + + private changed(): void { + this.snapshotDirty = true; + notifySubscribers(this.listeners, "jobs"); + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/client/service.ts b/apps/web-ui/packages/api/job-controller/src/client/service.ts new file mode 100644 index 000000000..d4a6d9cb0 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,298 @@ +/** + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.list` roster stream per watched session and one + * `job.follow` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor, plus the human kill passthrough over `job.kill`. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import { RemoteStreamCarrierError, type ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, +} from "../types.ts"; +import type { ClientJobsModel, JobsSource } from "./model.ts"; + +/** The generated `job` namespace face the stream runners drive. */ +export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + list(request: JobListRequest, signal?: AbortSignal): AsyncIterable; + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable; + /** + * Kill one job on the human's behalf. + * @param request - the session whose list carries the job, and the job id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(request: JobKillRequest): Promise>; +} + +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { + readonly $stream: ClientRemote["$stream"]; + readonly job: JobRemote; +} + +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource; + /** + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void; + /** + * Start observing one job's live output; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void; + /** + * Kill one background job from a session's job list. Pure RPC passthrough: + * row state converges through the roster stream, and the caller (the + * job-list control) owns error presentation. + * @param sessionId - session whose job list carries the job. + * @param id - the job row's registry id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(sessionId: SessionId, id: JobId): Promise>; +} + +/** One reference-counted stream. */ +interface StreamEntry { + refs: number; + stopped: boolean; + dispose: () => Promise; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free client job rosters and observation control. */ + jobs: IJobs; + } +} + +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource; + private readonly rowsEntries = new Map(); + private readonly observations = new Map(); + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client jobs model. + */ + constructor( + ctx: Context, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, + ) { + super(ctx, "jobs"); + this.state = model; + ctx.effect( + () => async () => { + const open = [...this.rowsEntries.values(), ...this.observations.values()]; + this.rowsEntries.clear(); + this.observations.clear(); + for (const entry of open) entry.stopped = true; + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map((entry) => entry.dispose())); + }, + "job-controller.client.streams", + ); + } + + kill(sessionId: SessionId, id: JobId): Promise> { + return this.remote.job.kill({ sessionId, jobId: id }); + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { + this.model.rowsDropped(sessionId); + }, + ); + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { + this.model.observeStopped(id); + }, + ); + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key); + if (existing !== undefined && !existing.stopped) { + existing.refs += 1; + return this.releaser(entries, key, existing, cleared); + } + const entry = start(); + entries.set(key, entry); + return this.releaser(entries, key, entry, cleared); + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. + */ + private releaser( + entries: Map, + key: string, + entry: StreamEntry, + cleared: () => void, + ): () => void { + let released = false; + return () => { + if (released) return; + released = true; + entry.refs -= 1; + if (entry.refs > 0) return; + if (entries.get(key) === entry) entries.delete(key); + entry.stopped = true; + void entry.dispose().then(() => { + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return; + cleared(); + }); + }; + } + + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}`; + const stream = this.remote.$stream({ + name, + open: (signal) => this.remote.job.list({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }); + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + }; + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs); + item.accept(); + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId); + } finally { + entry.stopped = true; + void entry.dispose(); + } + })(); + return entry; + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { + const name = `job observation ${String(id)}`; + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id); + return this.remote.job.follow( + { + jobId: id, + ...(sessionId !== undefined ? { sessionId } : {}), + ...(from !== undefined ? { from } : {}), + }, + signal, + ); + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }); + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + }; + void (async () => { + try { + for await (const item of stream) { + const frame = item.value; + if (frame.type === "opened") { + this.model.observeOpened(id, frame); + item.accept(); + continue; + } + if (frame.type === "output") { + this.model.observeOutput(id, frame); + continue; + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeSettled(id); + break; + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error); + } finally { + entry.stopped = true; + void entry.dispose(); + } + })(); + return entry; + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/index.ts b/apps/web-ui/packages/api/job-controller/src/index.ts new file mode 100644 index 000000000..268485995 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/index.ts @@ -0,0 +1,142 @@ +/** + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace, and stops a job on a human's behalf. The streams are + * projections of `ctx.jobs`; the model's consuming cursor and notice state + * never observe them, and a human kill is not the model's own, so the + * completion notice still reaches the owning agent. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-jobs"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { observeJobOutput } from "./observe.ts"; +import { streamJobRows } from "./rows.ts"; +import type { + JobKillRequest, + JobKillValue, + JobFollowFrame, + JobFollowRequest, + JobListFrame, + JobListRequest, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController; + } +} + +/** Default coalescing window between reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100; + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024; + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number; + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number; +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ["jobs", "typert"]; + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }); + + private readonly observeFlushMs: number; + private readonly observeMaxFrameBytes: number; + + /** + * @param ctx - Host context carrying the live Agent registry and the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, "jobController", { namespace: "job" }); + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required; + this.observeFlushMs = resolved.observeFlushMs; + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes; + } + + /** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: "stream" }) + list(request: JobListRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal); + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: "stream" }) + follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable { + return observeJobOutput( + this.ctx.jobs, + request, + { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + }, + signal, + ); + } + + /** + * Kill one background job on a human's behalf. The request's session is + * the fenced read's caller, so the job must be one that session can see: + * the registry's owner fence is the only access rule, and a child session's + * own jobs are killable from its list like any other. The kill records + * `cancelled by the user` as its reason; it is not one the model requested, + * so the owning agent still receives the completion notice, and a shell + * tool waiting on that job reads the reason in its own result. + * @param request - Session whose job list carries the job, and the job id. + * @returns the registry's admission of the kill request. + */ + @Remote("kill") + kill(request: JobKillRequest): JobKillValue { + const jobs = this.ctx.jobs; + try { + jobs.get(request.jobId, request.sessionId); + } catch (error) { + // `unknown job` and `belongs to another session` both mean this session's + // list no longer carries a killable row; the client renders one story. + throw new RemoteError("job/not-found", String(error), { + sessionId: request.sessionId, + jobId: request.jobId, + }); + } + // Same synchronous span as the lookup, so nothing can remove the job in + // between — and a producer-cancel throw propagates per the registry + // contract (job state unchanged) instead of masquerading as job-not-found. + const outcome = jobs.kill(request.jobId, request.sessionId, "cancelled by the user"); + return { outcome }; + } +} + +export default JobController; diff --git a/apps/web-ui/packages/api/job-controller/src/observe.ts b/apps/web-ui/packages/api/job-controller/src/observe.ts new file mode 100644 index 000000000..08ad43cba --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/observe.ts @@ -0,0 +1,134 @@ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ + +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobChunk, JobRegistry, JobStatus, JobView } from "@deepseek-ai/dsh-jobs"; +import type { JobFollowFrame, JobFollowRequest } from "./types.ts"; +import { OutputWaiter, sleep } from "./wake.ts"; + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number; + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number; +} + +function isTerminal(status: JobStatus): boolean { + return status !== "running" && status !== "stopping"; +} + +/** + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. A removal announced mid-generation (the owner's teardown) + * closes it with the removed job's terminal projection instead of a failed + * read. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. + * @param registry - the live job registry. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobOutput( + registry: JobRegistry, + request: JobFollowRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error( + `invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`, + ); + } + signal.throwIfAborted(); + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the registry package's constructor. + const id = String(request.jobId) as JobId; + const waiter = new OutputWaiter(); + // A removal announced during the generation: the owner's teardown settled + // and dropped the record, so the projection it announced is the last word + // and the read after the flush window would only find an unknown id. + let removed: JobView | undefined; + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: "all" }, (event) => { + const changed = event.type === "output" ? event.id : event.job.id; + if (changed !== id) return; + if (event.type === "removed") removed = event.job; + waiter.wake(); + }); + try { + let job = registry.get(id, request.sessionId); + let cursor = request.from ?? job.output.earliest; + yield { type: "opened", job, from: cursor }; + while (!signal.aborted) { + if (removed !== undefined) { + yield { type: "status", job: removed }; + return; + } + const read = registry.readAt(id, cursor, request.sessionId); + if (read.chunks.length > 0 || read.lossy) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes); + } + cursor = read.next; + job = registry.get(id, request.sessionId); + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: "status", job }; + return; + } + await waiter.wait(signal); + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal); + } + } finally { + unsubscribe(); + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly JobChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: JobChunk[] = []; + let batchBytes = 0; + let flaggedLossy = lossy; + for (const chunk of chunks) { + batch.push(chunk); + batchBytes += Buffer.byteLength(chunk.text, "utf8"); + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1]; + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, "utf8"); + /* v8 ignore stop */ + yield { + type: "output", + chunks: batch, + next: end, + ...(flaggedLossy ? { lossy: true as const } : {}), + }; + flaggedLossy = false; + batch = []; + batchBytes = 0; + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: "output", + chunks: batch, + next, + ...(flaggedLossy ? { lossy: true as const } : {}), + }; + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/rows.ts b/apps/web-ui/packages/api/job-controller/src/rows.ts new file mode 100644 index 000000000..f42bdd7c1 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/rows.ts @@ -0,0 +1,53 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from "@deepseek-ai/dsh-jobs"; +import type { JobListFrame, JobListRequest } from "./types.ts"; +import { OutputWaiter, sleep } from "./wake.ts"; + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number; +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobListRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted(); + const waiter = new OutputWaiter(); + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: "all" }, (event) => { + if (event.type === "output") return; + const owner = event.job.owner; + if (owner === undefined || owner === request.sessionId) waiter.wake(); + }); + try { + yield { type: "rows", jobs: registry.list(request.sessionId) }; + while (true) { + await waiter.wait(signal); + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal); + if (signal.aborted) return; + yield { type: "rows", jobs: registry.list(request.sessionId) }; + } + } finally { + unsubscribe(); + } +} diff --git a/apps/web-ui/packages/api/job-controller/src/types.ts b/apps/web-ui/packages/api/job-controller/src/types.ts new file mode 100644 index 000000000..dd06b3e60 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/types.ts @@ -0,0 +1,90 @@ +/** + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream, the per-job observation stream, and the human kill. Client-safe: no + * Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from "@deepseek-ai/dsh-jobs/brand"; +import type { JobChunk, JobView } from "@deepseek-ai/dsh-jobs/view"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; + +export type { JobChunk, JobView } from "@deepseek-ai/dsh-jobs/view"; + +/** Target of one `job.list` stream: the session whose visible jobs the stream mirrors. */ +export interface JobListRequest { + readonly sessionId: SessionId; +} + +/** + * One `job.list` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. + */ +export interface JobListFrame { + readonly type: "rows"; + readonly jobs: readonly JobView[]; +} + +/** Human-initiated cancellation of one background job visible to a session. */ +export interface JobKillRequest { + /** Session whose job list carries the job; the fenced lookup reads as it. */ + readonly sessionId: SessionId; + readonly jobId: JobId; +} + +/** Receipt after the registry accepted the human kill request. */ +export interface JobKillValue { + readonly outcome: "requested" | "already-finished"; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The session's job list no longer carries a killable row under that id. */ + "job/not-found": { readonly sessionId: SessionId; readonly jobId: JobId }; + } +} + +/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */ +export interface JobFollowRequest { + /** + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. + */ + readonly sessionId?: SessionId; + readonly jobId: JobId; + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number; +} + +/** + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. + */ +export type JobFollowFrame = + | { + readonly type: "opened"; + readonly job: JobView; + /** Offset the first `output` frame continues from. */ + readonly from: number; + } + | { + readonly type: "output"; + readonly chunks: readonly JobChunk[]; + /** Offset to resume from after this frame. */ + readonly next: number; + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true; + } + | { + readonly type: "status"; + readonly job: JobView; + }; diff --git a/apps/web-ui/packages/api/job-controller/src/wake.ts b/apps/web-ui/packages/api/job-controller/src/wake.ts new file mode 100644 index 000000000..16edbdce0 --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false; + private resolve: (() => void) | undefined; + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true; + this.resolve?.(); + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false; + return Promise.resolve(); + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener("abort", finish); + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined; + this.dirty = false; + resolve(); + }; + this.resolve = finish; + signal.addEventListener("abort", finish, { once: true }); + }); + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done(): void { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + signal.addEventListener("abort", done, { once: true }); + }); +} diff --git a/apps/web-ui/packages/api/job-controller/tsdown.config.ts b/apps/web-ui/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 000000000..e9e63c85e --- /dev/null +++ b/apps/web-ui/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-job-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/remotes/package.json b/apps/web-ui/packages/api/remotes/package.json new file mode 100644 index 000000000..bc63ed7fb --- /dev/null +++ b/apps/web-ui/packages/api/remotes/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-api-remotes", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-product-analytics": "*", + "@deepseek-ai/dsh-agent-preset-registry": "*", + "@deepseek-ai/dsh-user-questions": "*", + "@deepseek-ai/dsh-commands": "*", + "@deepseek-ai/dsh-api-account-controller": "*", + "@deepseek-ai/dsh-api-settings-controller": "*", + "@deepseek-ai/dsh-office-to-pdf": "*", + "@deepseek-ai/dsh-goal": "*", + "@deepseek-ai/dsh-schedule": "*", + "@deepseek-ai/dsh-llm": "*", + "@deepseek-ai/dsh-cordis-host-runner": "*", + "@deepseek-ai/dsh-plugin-manager": "*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "*", + "@deepseek-ai/dsh-host-plugin-inventory": "*", + "@deepseek-ai/dsh-message-feedback": "*", + "@deepseek-ai/dsh-permission-presets": "*", + "@deepseek-ai/dsh-command-feedback": "*", + "@deepseek-ai/dsh-client-file-upload": "*", + "@deepseek-ai/dsh-session-reference": "*", + "@deepseek-ai/dsh-subagent": "*", + "@deepseek-ai/dsh-api-session-controller": "*", + "@deepseek-ai/dsh-api-job-controller": "*", + "@deepseek-ai/dsh-api-workspace-controller": "*", + "@deepseek-ai/dsh-api-terminal-controller": "*", + "@deepseek-ai/dsh-api-workspace-files": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/api/remotes/src/client/index.ts b/apps/web-ui/packages/api/remotes/src/client/index.ts new file mode 100644 index 000000000..43c408bed --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/client/index.ts @@ -0,0 +1,251 @@ +/** Platform-neutral assembly of generated Host Remote contributions. */ + +import type { Context } from "@deepseek-ai/cordis"; +import productAnalyticsRemote from "@deepseek-ai/dsh-client-product-analytics/remote"; +export type {} from "@deepseek-ai/dsh-client-product-analytics/remote"; +import agentPresetsRemote from "@deepseek-ai/dsh-agent-preset-registry/remote"; +import userQuestionsRemote from "@deepseek-ai/dsh-user-questions/remote"; +import commandsRemote from "@deepseek-ai/dsh-commands/remote"; +import accountRemote from "@deepseek-ai/dsh-api-account-controller/remote"; +import settingsControllerRemote from "@deepseek-ai/dsh-api-settings-controller/remote"; +import officeToPdfRemote from "@deepseek-ai/dsh-office-to-pdf/remote"; +import goalsRemote from "@deepseek-ai/dsh-goal/remote"; +import scheduleRemote from "@deepseek-ai/dsh-schedule/remote"; +import llmRemote from "@deepseek-ai/dsh-llm/remote"; +import dynamicRemote from "@deepseek-ai/dsh-cordis-host-runner/remote"; +import pluginManagerRemote from "@deepseek-ai/dsh-plugin-manager/remote"; +import pluginRegistryProbeRemote from "@deepseek-ai/dsh-client-ui-plugin-manager/remote"; +import pluginInventoryRemote from "@deepseek-ai/dsh-host-plugin-inventory/remote"; +import messageFeedbackRemote from "@deepseek-ai/dsh-message-feedback/remote"; +import permissionPresetsRemote from "@deepseek-ai/dsh-permission-presets/remote"; +import sessionFeedbackRemote from "@deepseek-ai/dsh-command-feedback/remote"; +import fileUploadsRemote from "@deepseek-ai/dsh-client-file-upload/remote"; +import sessionReferencesRemote from "@deepseek-ai/dsh-session-reference/remote"; +import subagentsRemote from "@deepseek-ai/dsh-subagent/remote"; +import sessionRemote from "@deepseek-ai/dsh-api-session-controller/remote"; +import jobRemote from "@deepseek-ai/dsh-api-job-controller/remote"; +import workspaceRemote from "@deepseek-ai/dsh-api-workspace-controller/remote"; +import terminalRemote from "@deepseek-ai/dsh-api-terminal-controller/remote"; +import workspaceFilesRemote from "@deepseek-ai/dsh-api-workspace-files/remote"; +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; + +export type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +export type { + BundleInfo, + BundleRowInfo, + ChangeResult, + IncompatiblePlugin, + InspectOptions, + InstallBundleOptions, + InstallSpecKind, + ManagementError, + PackageResult, + PluginChange, + PluginEntryId, + PluginInfo, + PluginInspectProblem, + PluginInstallCancellation, + PluginInstallFailureKind, + PluginInstallLogChunk, + PluginInstallProgress, + PluginInstallRequestId, + PluginRegistries, + PluginSpecInspection, + ReadOnlyReason, + Registry, +} from "@deepseek-ai/dsh-plugin-manager/types"; +export type {} from "@deepseek-ai/dsh-plugin-manager/remote"; +export type {} from "@deepseek-ai/dsh-client-ui-plugin-manager/remote"; +export type { PluginInventorySnapshot } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +export type {} from "@deepseek-ai/dsh-agent-preset-registry/remote"; +export type {} from "@deepseek-ai/dsh-user-questions/remote"; +export type {} from "@deepseek-ai/dsh-commands/remote"; +export type {} from "@deepseek-ai/dsh-api-settings-controller/remote"; +export type {} from "@deepseek-ai/dsh-api-account-controller/remote"; +export type {} from "@deepseek-ai/dsh-goal/remote"; +export type {} from "@deepseek-ai/dsh-schedule/remote"; +export type {} from "@deepseek-ai/dsh-office-to-pdf/remote"; +export type {} from "@deepseek-ai/dsh-llm/remote"; +export type {} from "@deepseek-ai/dsh-host-plugin-inventory/remote"; +export type {} from "@deepseek-ai/dsh-message-feedback/remote"; +export type {} from "@deepseek-ai/dsh-permission-presets/remote"; +export type {} from "@deepseek-ai/dsh-command-feedback/remote"; +export type {} from "@deepseek-ai/dsh-client-file-upload/remote"; +export type {} from "@deepseek-ai/dsh-session-reference/remote"; +export type {} from "@deepseek-ai/dsh-subagent/remote"; +export type * from "@deepseek-ai/dsh-subagent/client"; +export type {} from "@deepseek-ai/dsh-api-session-controller/remote"; +export type * from "@deepseek-ai/dsh-api-session-controller/types"; +export type {} from "@deepseek-ai/dsh-api-job-controller/remote"; +export type * from "@deepseek-ai/dsh-api-job-controller/types"; +export type {} from "@deepseek-ai/dsh-api-workspace-controller/remote"; +export type * from "@deepseek-ai/dsh-api-workspace-controller/types"; +export type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; +export type * from "@deepseek-ai/dsh-api-workspace-files/types"; +export type {} from "@deepseek-ai/dsh-api-terminal-controller/remote"; +export type * from "@deepseek-ai/dsh-api-terminal-controller/types"; +// The forwarded-event allowlist's selection seat: without it in the consumer's +// compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. +export type { ApiRemoteForwardedEvent } from "../types.ts"; +// The owner packages' client-safe `./types` exports supply the `Events` +// signatures `$on` hands to a listener, so a consumer reads the very +// declaration the Host emits rather than a flattened restatement of it. +export type {} from "@deepseek-ai/dsh-commands/types"; +export type {} from "@deepseek-ai/dsh-cordis-host-runner/types"; +export type {} from "@deepseek-ai/dsh-credentials/types"; +export type {} from "@deepseek-ai/dsh-llm/types"; +export type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +export type {} from "@deepseek-ai/dsh-permission-presets/types"; +export type {} from "@deepseek-ai/dsh-settings/types"; +export type {} from "@deepseek-ai/dsh-user-approval/types"; +export type {} from "@deepseek-ai/dsh-user-questions/types"; +export type {} from "@deepseek-ai/dsh-api-session-controller/types"; + +/** + * The carrier's Client-facing types, re-exported so a business package names one + * assembly package instead of both this facade and the Connection plugin. Type-only: + * the carrier's runtime values stay behind their own module edge. + */ +export type { + ConnectionHandle, + ConnectionSinks, + ContentBlock, + MessageId, + RpcId, + RpcRequest, + RpcResponse, + RpcResult, + SessionId, + StreamChunk, +} from "@deepseek-ai/dsh-client-connection/client"; +export type {} from "@deepseek-ai/dsh-api-gateway/client"; +export type {} from "@deepseek-ai/dsh-cordis-host-runner/remote"; + +// The payload vocabulary of the selected namespaces, re-exported so a Client +// contribution can name what it sends and receives without importing a Host +// package: this assembly is the one place both planes legitimately meet. +export type { + ApprovalRequestId, + CordisHalfState, + CordisDynamicPackageId, + CordisDynamicPluginId, + CordisDynamicPluginRunId, + CordisDynamicRunMode, + CordisInspectMethodManifest, + CordisInspectPlatform, + CordisInspectProviderManifest, + CordisInspectProviderView, + CordisInspectQueryRequest, + CordisInspectQueryResolution, + CordisInspectQueryResolved, + CordisInspectRequestId, + CordisInspectResolveAck, + CordisRunDiagnostic, + CordisRunStatus, + DynamicCordisClientSource, + DynamicCordisHostHalfResult, + DynamicCordisInventoryRow, + DynamicCordisInvokeResult, + DynamicCordisPackage, + DynamicCordisRequestResolved, + DynamicCordisResolveAck, + DynamicCordisRetracted, + DynamicCordisRunRequest, + DynamicCordisRunResolution, + DynamicCordisRunAttempt, + DynamicCordisRunResponse, + DynamicCordisStopResponse, + DynamicCordisUndefineReceipt, + RequestRunOutcome, +} from "@deepseek-ai/dsh-cordis-host-runner/types"; +// Credential state vocabulary for the credentials namespace (values never ride it). +export type { CredentialInfo } from "@deepseek-ai/dsh-credentials/types"; +// Redacted namespace vocabulary for the settings namespace (secrets never ride +// it). It travels with its seam, whose `./types` the Client face already reads. +export type { + SettingsDescribeValue, + SettingsNamespaceView, + SettingsPathOpView, + SettingsSecretView, +} from "@deepseek-ai/dsh-settings/types"; +// Provider registry and discovery vocabulary for the llm namespace. +export type { + LlmConfigurableProvider, + LlmDiscoveredModel, + LlmModelDiscoveryRequest, + LlmProviderInfo, +} from "@deepseek-ai/dsh-llm/types"; +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from "@deepseek-ai/dsh-file-reference/types"; +export type { SessionReferenceMentionCandidate } from "@deepseek-ai/dsh-session-reference/types"; + +// The Remote failure vocabulary, re-exported so business packages keep naming +// this assembly alone. Types only: a value export would make spec imports load +// this module's owner /remote artifacts; specs take RemoteError from +// dsh-client-test-runtime instead. +export type { + RemoteErrorCode, + RemoteErrorDetailsMap, + RemoteFailure, + RemoteResult, +} from "@deepseek-ai/dsh-typert-protocol"; +export type { RemoteHostFacts } from "@deepseek-ai/dsh-api-gateway/client"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Generated Remote namespaces selected by this Client assembly. */ + remote: ClientRemote; + } +} + +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ["remote"]; + +/** + * Mount the Host capabilities explicitly selected for this Client assembly. + * @param ctx - Client Cordis root carrying the typed API service. + * @returns disposer after every selected Remote namespace is ready. + */ +export async function apply(ctx: Context): Promise<() => Promise> { + const disposers: Array<() => Promise> = []; + try { + for (const contribution of [ + productAnalyticsRemote, + agentPresetsRemote, + commandsRemote, + settingsControllerRemote, + accountRemote, + goalsRemote, + llmRemote, + dynamicRemote, + scheduleRemote, + pluginInventoryRemote, + pluginManagerRemote, + pluginRegistryProbeRemote, + messageFeedbackRemote, + sessionFeedbackRemote, + fileUploadsRemote, + sessionReferencesRemote, + permissionPresetsRemote, + subagentsRemote, + sessionRemote, + jobRemote, + workspaceRemote, + workspaceFilesRemote, + terminalRemote, + officeToPdfRemote, + userQuestionsRemote, + ]) { + disposers.push(await ctx.remote.$mount(contribution)); + } + } catch (error) { + for (const dispose of disposers.reverse()) await dispose(); + throw error; + } + // Unwound in reverse mount order, so a namespace never outlives one mounted + // after it. + return async () => { + for (const dispose of disposers.reverse()) await dispose(); + }; +} diff --git a/apps/web-ui/packages/api/remotes/src/index.ts b/apps/web-ui/packages/api/remotes/src/index.ts new file mode 100644 index 000000000..3782d9de5 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/index.ts @@ -0,0 +1,182 @@ +/** Host BFF entry and Loader shell for the Remote contribution assembly. */ + +import { homedir } from "node:os"; +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { + TypertRemoteEventDispatch, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from "@deepseek-ai/dsh-api-gateway"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { carrierKeyOf } from "@deepseek-ai/dsh-scope"; +import { isJsonValue, type JsonValue } from "@deepseek-ai/dsh-util-values"; +import { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; + +// The owner packages' client-safe `./types` exports carry the cordis `Events` +// declarations for every allowlisted event. Pulling them into this face is what +// makes the shape assertion below judge real signatures rather than an empty +// event vocabulary. +import type {} from "@deepseek-ai/dsh-commands/types"; +import type {} from "@deepseek-ai/dsh-cordis-host-runner/types"; +import type {} from "@deepseek-ai/dsh-credentials/types"; +import type {} from "@deepseek-ai/dsh-goal/types"; +import type {} from "@deepseek-ai/dsh-llm/types"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +import type {} from "@deepseek-ai/dsh-permission-presets/types"; +import type {} from "@deepseek-ai/dsh-settings/types"; +import type {} from "@deepseek-ai/dsh-user-approval"; +import type {} from "@deepseek-ai/dsh-user-questions"; +export type {} from "@deepseek-ai/dsh-api-session-controller/types"; + +export { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; +export type { ApiRemoteForwardedEvent } from "./types.ts"; + +/** Required Host service: the Gateway owns the physical Remote stream mux. */ +export const inject = ["typertGateway"]; + +/** Host plugin body registering this application's selected Cordis event source. */ +export function apply(ctx: Context): void { + ctx.effect( + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }), + "api-remotes: forwarded Cordis event source", + ); +} + +/** Create the sole queue and listener set consumed by the registered Gateway. */ +function remoteEventSource(ctx: Context): TypertRemoteEventSource { + return (signal) => { + const queue = new RemoteEventQueue(); + const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + if (mode === "emit") { + return ctx.on( + event as never, + ((...args: unknown[]) => { + queue.push({ event, args: assertJsonArgs(event, args) }); + }) as never, + ); + } + return ctx.on( + event as never, + function (this: unknown, request: object, next: () => unknown) { + const carrierAgent = carrierKeyOf(this); + if (carrierAgent === undefined) return next(); + const agent = (request as { readonly agent?: Agent }).agent; + if (agent === undefined || agent !== carrierAgent) { + throw new TypeError( + `forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`, + ); + } + return forwardWaterfall( + queue, + event, + request, + { value: agent.ctx, subject: agent, agentId: agent.id }, + next, + ); + } as never, + ); + }); + return queue.iterate(signal, () => { + for (const dispose of disposers) dispose(); + }); + }; +} + +/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ +class RemoteEventQueue { + private readonly buffer = new Deque(); + private waiter: (() => void) | undefined; + private done = false; + + push(frame: TypertRemoteEventDispatch): boolean { + if (this.done) return false; + this.buffer.pushBack(frame); + this.waiter?.(); + return true; + } + + private end(reason: unknown): void { + if (this.done) return; + this.done = true; + while (this.buffer.size > 0) { + const dispatch = this.buffer.popFront() as TypertRemoteEventDispatch; + if ("context" in dispatch) dispatch.reject(reason); + } + this.waiter?.(); + } + + async *iterate( + signal: AbortSignal, + cleanup: () => void, + ): AsyncGenerator { + const abort = (): void => { + this.end(remoteEventSourceEndReason(signal)); + }; + signal.addEventListener("abort", abort, { once: true }); + try { + while (true) { + if (this.done || signal.aborted) return; + while (this.buffer.size > 0) yield this.buffer.popFront() as TypertRemoteEventDispatch; + await new Promise((resolve) => { + this.waiter = resolve; + }); + this.waiter = undefined; + } + } finally { + signal.removeEventListener("abort", abort); + this.end(remoteEventSourceEndReason(signal)); + cleanup(); + } + } +} + +/** + * Normalize an event-source shutdown for pending Host waterfalls. + * @param signal - source lifetime whose reason wins after cancellation. + * @returns the cancellation reason or an unexpected-end failure. + */ +function remoteEventSourceEndReason(signal: AbortSignal): unknown { + if (signal.aborted) return signal.reason; + return new Error("api-remotes: forwarded Remote event source ended"); +} + +/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */ +function forwardWaterfall( + queue: RemoteEventQueue, + event: string, + request: object, + context: TypertRemoteEventInvocation["context"], + next: () => unknown, +): Promise { + const settled = Promise.withResolvers(); + const dispatch: TypertRemoteEventInvocation = { + event, + request, + context, + resolve: (outcome: TypertRemoteEventOutcome) => { + if (outcome.kind === "result") { + settled.resolve(outcome.value); + return; + } + void Promise.resolve().then(next).then(settled.resolve, settled.reject); + }, + reject: settled.reject, + }; + if (!queue.push(dispatch)) + void Promise.resolve().then(next).then(settled.resolve, settled.reject); + return settled.promise; +} + +/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */ +function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error( + `forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`, + ); + } + } + return args as JsonValue[]; +} diff --git a/apps/web-ui/packages/api/remotes/src/remote-events.ts b/apps/web-ui/packages/api/remotes/src/remote-events.ts new file mode 100644 index 000000000..7dc9341b1 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/remote-events.ts @@ -0,0 +1,48 @@ +/** + * The one home of this application's forwarded-Host-event allowlist. Both + * compiler faces list this file, so the Host forwarding loop and the consumer + * `ctx.remote.$on` key face read one declaration instead of two copies that + * could drift; `./types.ts` derives the type projection from it and stays + * type-only. + */ + +import type {} from "@deepseek-ai/dsh-api-session-controller/remote-events"; +import type {} from "@deepseek-ai/dsh-deepseek-account/types"; +import type {} from "@deepseek-ai/dsh-permission-presets/types"; +import type {} from "@deepseek-ai/dsh-plugin-manager/types"; +import type {} from "@deepseek-ai/dsh-schedule/client"; +import type { TypertForwardableEventEntry } from "@deepseek-ai/dsh-typert-protocol"; + +/** + * Host events this application forwards without renaming. The explicit mode is + * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. + */ +export const API_REMOTE_FORWARDED_EVENTS = [ + { event: "agent-preset/selected", mode: "emit" }, + { event: "approval/request", mode: "waterfall" }, + { event: "api-session/activity", mode: "emit" }, + { event: "api-session/added", mode: "emit" }, + { event: "api-session/error", mode: "emit" }, + { event: "api-session/removed", mode: "emit" }, + { event: "api-session/status", mode: "emit" }, + { event: "commands/change", mode: "emit" }, + { event: "deepseek-account/session-expired", mode: "emit" }, + { event: "deepseek-account/model-sign-in-required", mode: "emit" }, + { event: "credentials/record-updated", mode: "emit" }, + { event: "credentials/reference-updated", mode: "emit" }, + { event: "goal/activation-changed", mode: "emit" }, + { event: "cordis/request-run", mode: "emit" }, + { event: "cordis/request-run-resolved", mode: "emit" }, + { event: "cordis/dynamic-package", mode: "emit" }, + { event: "cordis/dynamic-retract", mode: "emit" }, + { event: "cordis/inspect-query", mode: "emit" }, + { event: "cordis/inspect-query-resolved", mode: "emit" }, + { event: "llm/adapters-updated", mode: "emit" }, + { event: "permission-presets/catalog-changed", mode: "emit" }, + { event: "plugin-manager/changed", mode: "emit" }, + { event: "plugin-manager/install-log", mode: "emit" }, + { event: "plugin-manager/install-state", mode: "emit" }, + { event: "settings/document-updated", mode: "emit" }, + { event: "schedule/changed", mode: "emit" }, + { event: "user-questions/request", mode: "waterfall" }, +] as const satisfies readonly TypertForwardableEventEntry[]; diff --git a/apps/web-ui/packages/api/remotes/src/types.ts b/apps/web-ui/packages/api/remotes/src/types.ts new file mode 100644 index 000000000..109dd5808 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/src/types.ts @@ -0,0 +1,19 @@ +/** + * Type face of the forwarded-Host-event allowlist: the consumer key projection + * and the selection seat it fills. The allowlist VALUE lives in + * `./remote-events.ts`, keeping this module type-only per the package + * convention; both compiler faces list both files, so the Host forwarding loop + * and the consumer `ctx.remote.$on` key face read one declaration instead of + * two copies that could drift. + * + * @module @deepseek-ai/dsh-api-remotes/types + */ + +import type { API_REMOTE_FORWARDED_EVENTS } from "./remote-events.ts"; + +/** Type projection of the allowlist; the consumer and the Host read this one. */ +export type ApiRemoteForwardedEvent = (typeof API_REMOTE_FORWARDED_EVENTS)[number]["event"]; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertRemoteEventSelection extends Record {} +} diff --git a/apps/web-ui/packages/api/remotes/tsdown.config.ts b/apps/web-ui/packages/api/remotes/tsdown.config.ts new file mode 100644 index 000000000..31dc98964 --- /dev/null +++ b/apps/web-ui/packages/api/remotes/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-remotes", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/session-controller/generated/remote.d.ts b/apps/web-ui/packages/api/session-controller/generated/remote.d.ts new file mode 100644 index 000000000..853e78868 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/generated/remote.d.ts @@ -0,0 +1,74 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, SessionCancelValue, SessionControlFrame, SessionCreateRequest, SessionCreateValue, SessionFollowFrame, SessionFollowRequest, SessionForkRequest, SessionForkValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionProjectionsRequest, SessionProjectionsValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, SessionRenameValue, SessionSearchRequest, SessionSearchValue, SessionSelectModelRequest, SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, SessionWorkspacePathApplication, SkillListRequest, SkillListValue } from '@deepseek-ai/dsh-api-session-controller/types' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$66696c655265666572656e636573 { + list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$73657373696f6e { + attachment: (request: SessionAttachmentRequest) => Promise> + cancel: (request: SessionCancelRequest) => Promise> + canOpenWorkspacePath: () => Promise> + control: (signal?: AbortSignal) => RemoteStreamHandle + create: (request: SessionCreateRequest) => Promise> + follow: (request: SessionFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + fork: (request: SessionForkRequest) => Promise> + initializeDefaultModel: () => Promise> + list: (_request: SessionListRequest, signal?: AbortSignal) => Promise> + modelCatalog: () => Promise> + openWorkspacePath: (request: SessionOpenWorkspacePathRequest, signal?: AbortSignal) => Promise> + page: (request: SessionPageRequest, signal?: AbortSignal) => Promise> + projections: (request: SessionProjectionsRequest, signal?: AbortSignal) => Promise> + prompt: (request: SessionPromptRequest, signal?: AbortSignal) => Promise> + rename: (request: SessionRenameRequest) => Promise> + search: (request: SessionSearchRequest, signal?: AbortSignal) => Promise> + selectModel: (request: SessionSelectModelRequest) => Promise> + updateQueue: (request: SessionUpdateQueueRequest) => Promise> + workspacePathApplications: (request: { readonly path: string; }, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$736b696c6c73 { + list: (request: SkillListRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'fileReferences/list': (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + 'session/attachment': (request: SessionAttachmentRequest) => Promise> + 'session/cancel': (request: SessionCancelRequest) => Promise> + 'session/canOpenWorkspacePath': () => Promise> + 'session/control': (signal?: AbortSignal) => RemoteStreamHandle + 'session/create': (request: SessionCreateRequest) => Promise> + 'session/follow': (request: SessionFollowRequest, signal?: AbortSignal) => RemoteStreamHandle + 'session/fork': (request: SessionForkRequest) => Promise> + 'session/initializeDefaultModel': () => Promise> + 'session/list': (_request: SessionListRequest, signal?: AbortSignal) => Promise> + 'session/modelCatalog': () => Promise> + 'session/openWorkspacePath': (request: SessionOpenWorkspacePathRequest, signal?: AbortSignal) => Promise> + 'session/page': (request: SessionPageRequest, signal?: AbortSignal) => Promise> + 'session/projections': (request: SessionProjectionsRequest, signal?: AbortSignal) => Promise> + 'session/prompt': (request: SessionPromptRequest, signal?: AbortSignal) => Promise> + 'session/rename': (request: SessionRenameRequest) => Promise> + 'session/search': (request: SessionSearchRequest, signal?: AbortSignal) => Promise> + 'session/selectModel': (request: SessionSelectModelRequest) => Promise> + 'session/updateQueue': (request: SessionUpdateQueueRequest) => Promise> + 'session/workspacePathApplications': (request: { readonly path: string; }, signal?: AbortSignal) => Promise> + 'skills/list': (request: SkillListRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'fileReferences': TypertRemoteNamespace$66696c655265666572656e636573 + 'session': TypertRemoteNamespace$73657373696f6e + 'skills': TypertRemoteNamespace$736b696c6c73 + } + interface TypertRemoteScopeMap { + 'agent:fileReferences/list': (query: string, signal?: AbortSignal) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/session-controller/generated/remote.js b/apps/web-ui/packages/api/session-controller/generated/remote.js new file mode 100644 index 000000000..6dc248b1c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/generated/remote.js @@ -0,0 +1,1472 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let JsonValueRemoteCodec$schema$value +const JsonValueRemoteCodec$schema = () => (JsonValueRemoteCodec$schema$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])) +let JsonValueRemoteCodec$schema2$value +const JsonValueRemoteCodec$schema2 = () => (JsonValueRemoteCodec$schema2$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))])) +let JsonValueRemoteCodec$schema3$value +const JsonValueRemoteCodec$schema3 = () => (JsonValueRemoteCodec$schema3$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])) +let JsonValueRemoteCodec$schema4$value +const JsonValueRemoteCodec$schema4 = () => (JsonValueRemoteCodec$schema4$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])) +let JsonValueRemoteCodec$schema5$value +const JsonValueRemoteCodec$schema5 = () => (JsonValueRemoteCodec$schema5$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema$value ??= z.array(z.object({ + 'path': z.string(), + 'kind': z.union([z.literal("file"), z.literal("directory")]), +}))) +let _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'attachmentId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_attachment_result$schema$value ??= z.object({ + 'attachment': z.object({ + 'attachmentId': z.intersection(z.string(), z.unknown()), + 'mediaType': z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")]), + 'bytes': z.number(), + 'width': z.number(), + 'height': z.number(), + 'name': z.string().optional(), + 'originalDimensions': z.object({ + 'width': z.number(), + 'height': z.number(), +}).optional(), +}).readonly(), + 'data': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_cancel_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_api_session_controller_session_control_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_control_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_control_result$schema$value ??= z.union([z.object({ + 'type': z.literal("baseline").readonly(), + 'value': z.object({ + 'projections': z.record(z.intersection(z.string(), z.unknown()), z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])).readonly()).readonly(), +})).readonly().readonly(), +}).readonly(), +}), z.intersection(z.object({ + 'type': z.literal("projection").readonly(), +}), z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'key': z.string().readonly(), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))]).readonly(), + 'seq': z.number().readonly(), +}))])) +let _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'cwd': z.string().readonly().optional(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'agentPreset': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_create_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_create_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_create_result$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'agentPreset': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema$value ??= z.object({ + 'address': z.union([z.object({ + 'kind': z.literal("session").readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'kind': z.literal("subagent").readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'childSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("one-shot"), z.literal("continuable"), z.literal("unknown")]).readonly(), +})]).readonly(), + 'assistantStream': z.literal(true).readonly().optional(), + 'maxMessages': z.number().readonly().optional(), + 'turnWindow': z.object({ + 'minMessages': z.number().readonly(), + 'minTurns': z.number().readonly(), +}).readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_follow_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_follow_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), +}).readonly(), +}), z.object({ + 'type': z.literal("snapshot").readonly(), + 'header': z.object({ + 'version': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), + 'cwd': z.string().readonly().optional(), + 'parentSession': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'isSeeded': z.boolean().readonly(), + 'origin': z.literal("subagent").readonly().optional(), + 'delegationDepth': z.number().readonly().optional(), + 'agentPreset': z.string().readonly().optional(), +}).readonly(), + 'cursor': z.number().readonly(), + 'records': z.array(z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly().optional(), +}).readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), + 'projections': z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly()).readonly(), +}).readonly(), + 'assistantStream': z.object({ + 'revision': z.number().readonly(), + 'activeAttempt': z.object({ + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'startedAfterSeq': z.union([z.intersection(z.number(), z.unknown()), z.literal(-1)]).readonly(), + 'turn': z.number().readonly(), + 'step': z.number().readonly(), + 'nextIndex': z.number().readonly(), + 'stream': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])).readonly(), +}).readonly().optional(), +}).readonly().optional(), +}), z.object({ + 'type': z.literal("assistant-stream").readonly(), + 'frame': z.union([z.object({ + 'type': z.literal("start").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'startedAfterSeq': z.union([z.intersection(z.number(), z.unknown()), z.literal(-1)]).readonly(), + 'turn': z.number().readonly(), + 'step': z.number().readonly(), +}), z.object({ + 'type': z.literal("chunk").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'index': z.number().readonly(), + 'time': z.number().readonly(), + 'chunk': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]).readonly(), +}), z.object({ + 'type': z.literal("end").readonly(), + 'attemptId': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), + 'index': z.number().readonly(), + 'outcome': z.union([z.object({ + 'kind': z.literal("committed").readonly(), + 'eventType': z.union([z.literal("assistant/message"), z.literal("assistant/attempt")]).readonly(), + 'seq': z.number().readonly(), +}), z.object({ + 'kind': z.literal("abandoned").readonly(), +})]).readonly(), +})]).readonly(), +})])) +let _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'atSeq': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_fork_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_fork_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_fork_result$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema$value ??= z.object({ + 'cursor': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_list_result$schema$value ??= z.object({ + 'items': z.array(z.object({ + 'agentAvailable': z.boolean().readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'updatedAt': z.number().readonly(), + 'running': z.boolean().readonly(), + 'blank': z.boolean().readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), + 'origin': z.literal("subagent").readonly().optional(), + 'cwd': z.string().readonly().optional(), + 'projections': z.object({ + 'kind': z.union([z.literal("cached"), z.literal("sequenced")]).readonly(), + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])).readonly()).readonly(), +}).readonly().optional(), +})).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema$value ??= z.object({ + 'default': z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +}).readonly(), + 'routableProviders': z.array(z.string()).readonly(), + 'groups': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'models': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'description': z.string().readonly().optional(), + 'reasoning': z.object({ + 'efforts': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'description': z.string().readonly().optional(), +})).readonly(), + 'defaultEffort': z.string().readonly().optional(), +}).readonly().optional(), +})).readonly(), +})).readonly(), + 'failures': z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'message': z.string().readonly(), +})).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema$value ??= z.object({ + 'action': z.literal("reveal").readonly().optional(), + 'application': z.string().readonly().optional(), + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema$value ??= z.object({ + 'opened': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema$value ??= z.object({ + 'address': z.union([z.object({ + 'kind': z.literal("session").readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'kind': z.literal("subagent").readonly(), + 'parentSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'childSessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("one-shot"), z.literal("continuable"), z.literal("unknown")]).readonly(), +})]).readonly(), + 'throughSeq': z.number().readonly(), + 'beforeSeq': z.number().readonly().optional(), + 'maxMessages': z.number().readonly().optional(), + 'turnWindow': z.object({ + 'minMessages': z.number().readonly(), + 'minTurns': z.number().readonly(), +}).readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_page_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_page_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_page_result$schema$value ??= z.object({ + 'records': z.array(z.object({ + 'type': z.literal("event").readonly(), + 'event': z.object({ + 'type': z.string().readonly(), + 'seq': z.number().readonly(), + 'time': z.number().readonly(), + 'data': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly(), + 'ignorable': z.literal(true).readonly().optional(), + 'sourceEventSeqs': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly().optional(), + 'surfaceOp': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).readonly().optional(), +}).readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_projections_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_projections_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_projections_result$schema$value ??= z.union([z.literal(null), z.object({ + 'asOfSeq': z.number().readonly(), + 'values': z.intersection(z.object({ + 'inbox': z.object({ + 'next-turn': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly(), + 'next-step': z.array(z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly(), +}).optional(), + 'agentPreset': z.union([z.literal(null), z.string()]).optional(), + 'title': z.union([z.literal(null), z.string()]).optional(), + 'todos': z.union([z.literal(null), z.array(z.object({ + 'content': z.string(), + 'status': z.union([z.literal("completed"), z.literal("pending"), z.literal("in_progress")]), +}))]).optional(), + 'sessionListMetadata': z.object({ + 'blank': z.boolean().readonly(), + 'lastPromptAt': z.union([z.literal(null), z.number()]).readonly(), +}).optional(), + 'imageLimits': z.object({ + 'maxImageBytes': z.number(), + 'maxImagesPerMessage': z.number(), + 'maxMessageImageBytes': z.number(), + 'maxImagePixels': z.number(), + 'maxImageDimension': z.number(), + 'mediaTypes': z.array(z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")])), +}).optional(), + 'modelSelection': z.object({ + 'lastUsed': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), + 'next': z.union([z.literal(null), z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})]).readonly(), +}).optional(), + 'permissions': z.object({ + 'currentValue': z.string(), +}).optional(), + 'subagentCatalog': z.array(z.union([z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("one-shot").readonly(), + 'label': z.string().readonly().optional(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("continuable").readonly(), + 'label': z.string().readonly(), +})), z.intersection(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'createdAt': z.number().readonly(), +}), z.object({ + 'mode': z.literal("unknown").readonly(), + 'label': z.string().readonly().optional(), +}))])).optional(), + 'subagentTiming': z.object({ + 'settledMs': z.number(), + 'active': z.object({ + 'since': z.number(), + 'through': z.number(), +}).optional(), + 'lastTurnCompleted': z.boolean().optional(), +}).optional(), + 'subagent': z.union([z.literal(null), z.object({ + 'mode': z.literal("one-shot"), + 'label': z.string().optional(), + 'seq': z.intersection(z.number(), z.unknown()), +}), z.object({ + 'mode': z.literal("continuable"), + 'label': z.string(), + 'seq': z.intersection(z.number(), z.unknown()), +})]).optional(), + 'tokenUsage': z.object({ + 'uncachedInputTokens': z.number(), + 'outputTokens': z.number(), + 'cacheReadTokens': z.number(), + 'cacheWriteTokens': z.number(), +}).optional(), + 'contextPressure': z.object({ + 'pressureTokens': z.number().optional(), + 'projectedTokens': z.number().optional(), + 'contextWindow': z.number().optional(), +}).optional(), + 'contextBreakdown': z.object({ + 'systemTokens': z.number(), + 'toolsTokens': z.number(), + 'messageTokens': z.number(), +}).optional(), + 'userQuestions': z.object({ + 'active': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'questions': z.array(z.object({ + 'id': z.string(), + 'question': z.string(), + 'detail': z.string().optional(), + 'header': z.string().optional(), + 'options': z.array(z.object({ + 'label': z.string(), + 'description': z.string().optional(), +})).optional(), + 'multiSelect': z.boolean().optional(), + 'intent': z.object({ + 'kind': z.literal("plan-review"), + 'approve': z.string(), + 'callId': z.intersection(z.string(), z.unknown()).optional(), +}).optional(), +})).readonly(), + 'state': z.union([z.literal("open"), z.literal("continued")]).readonly(), +})).readonly(), + 'settled': z.array(z.object({ + 'callId': z.intersection(z.string(), z.unknown()).readonly(), + 'answers': z.array(z.object({ + 'id': z.string(), + 'selected': z.array(z.string()), + 'custom': z.string().optional(), +})).readonly(), +})).readonly(), +}).optional(), + 'goal': z.union([z.literal(null), z.object({ + 'goal': z.object({ + 'objective': z.string().readonly(), + 'phase': z.union([z.literal("active"), z.literal("paused"), z.literal("blocked"), z.literal("complete")]).readonly(), + 'blockedReason': z.object({ + 'code': z.string().readonly(), + 'message': z.string().readonly(), +}).readonly().optional(), + 'maxGoalRounds': z.number().readonly(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'revision': z.number().readonly(), +}).readonly(), + 'roundsStarted': z.number().readonly(), + 'createdAt': z.number().readonly(), + 'updatedAt': z.number().readonly(), +})]).optional(), +}), z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])).readonly()).readonly(), +})])) +let _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema$value ??= z.object({ + 'requestId': z.intersection(z.string(), z.unknown()).readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'mode': z.union([z.literal("queue"), z.literal("steer")]).readonly(), + 'content': z.array(z.union([z.object({ + 'type': z.literal("text").readonly(), + 'text': z.string().readonly(), +}), z.object({ + 'type': z.literal("image").readonly(), + 'mediaType': z.union([z.literal("image/png"), z.literal("image/jpeg"), z.literal("image/webp"), z.literal("image/gif")]).readonly(), + 'data': z.string().readonly(), + 'name': z.string().readonly().optional(), +}), z.object({ + 'type': z.literal("file").readonly(), + 'receiptId': z.intersection(z.string(), z.unknown()).readonly(), +})])).readonly(), + 'clientTimeZone': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_prompt_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_rename_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_rename_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_rename_result$schema$value ??= z.object({ + 'title': z.string().readonly(), + 'seq': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema$value ??= z.object({ + 'query': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_search_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_search_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_search_result$schema$value ??= z.object({ + 'items': z.array(z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'snippet': z.string().readonly(), +})).readonly(), + 'hasMore': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema$value ??= z.object({ + 'selected': z.object({ + 'provider': z.string().readonly(), + 'model': z.string().readonly(), + 'reasoningEffort': z.string().readonly().optional(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'itemId': z.intersection(z.string(), z.unknown()).readonly(), + 'action': z.union([z.object({ + 'kind': z.literal("edit").readonly(), + 'content': z.array(z.object({ + 'type': z.literal("text"), + 'text': z.string(), +})).readonly(), +}), z.object({ + 'kind': z.literal("remove").readonly(), +}), z.object({ + 'kind': z.literal("steer").readonly(), +})]).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema$value ??= z.object({ + 'accepted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema$value ??= z.object({ + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema$value +const _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema = () => (_deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema$value ??= z.array(z.object({ + 'id': z.string().readonly(), + 'name': z.string().readonly(), + 'default': z.boolean().readonly(), + 'icon': z.union([z.literal(null), z.string()]).readonly(), +}))) +let _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_session_controller_skills_list_result$schema$value +const _deepseek_ai_dsh_api_session_controller_skills_list_result$schema = () => (_deepseek_ai_dsh_api_session_controller_skills_list_result$schema$value ??= z.object({ + 'skills': z.array(z.object({ + 'path': z.string().readonly().optional(), + 'name': z.string().readonly(), + 'description': z.string().readonly(), + 'whenToUse': z.string().readonly().optional(), + 'modelInvocable': z.boolean().readonly(), +})).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-session-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-session-controller#fileReferences/list', + service: 'sessionFileReferences', + namespace: 'fileReferences', + method: 'list', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_0$schema, + }, + }, + { + name: 'query', + wire: 'query', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#fileReferences/list:query', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#fileReferences/list:result', + create: _deepseek_ai_dsh_api_session_controller_fileReferences_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/file-references.ts","line":33,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/attachment', + service: 'sessionController', + namespace: 'session', + method: 'attachment', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionAttachmentRequest', + create: _deepseek_ai_dsh_api_session_controller_session_attachment_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionAttachmentValue', + create: _deepseek_ai_dsh_api_session_controller_session_attachment_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":444,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/cancel', + service: 'sessionController', + namespace: 'session', + method: 'cancel', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCancelRequest', + create: _deepseek_ai_dsh_api_session_controller_session_cancel_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCancelValue', + create: _deepseek_ai_dsh_api_session_controller_session_cancel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":464,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/canOpenWorkspacePath', + service: 'sessionController', + namespace: 'session', + method: 'canOpenWorkspacePath', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/canOpenWorkspacePath:result', + create: _deepseek_ai_dsh_api_session_controller_session_canOpenWorkspacePath_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":326,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/control', + service: 'sessionController', + namespace: 'session', + method: 'control', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionControlFrame', + create: _deepseek_ai_dsh_api_session_controller_session_control_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":527,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/create', + service: 'sessionController', + namespace: 'session', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCreateRequest', + create: _deepseek_ai_dsh_api_session_controller_session_create_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionCreateValue', + create: _deepseek_ai_dsh_api_session_controller_session_create_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":280,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/follow', + service: 'sessionController', + namespace: 'session', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionFollowRequest', + create: _deepseek_ai_dsh_api_session_controller_session_follow_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionFollowFrame', + create: _deepseek_ai_dsh_api_session_controller_session_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":487,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/fork', + service: 'sessionController', + namespace: 'session', + method: 'fork', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionForkRequest', + create: _deepseek_ai_dsh_api_session_controller_session_fork_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionForkValue', + create: _deepseek_ai_dsh_api_session_controller_session_fork_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":422,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/initializeDefaultModel', + service: 'sessionController', + namespace: 'session', + method: 'initializeDefaultModel', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/initializeDefaultModel:result', + create: _deepseek_ai_dsh_api_session_controller_session_initializeDefaultModel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":299,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/list', + service: 'sessionController', + namespace: 'session', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: '_request', + wire: '_request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionListRequest', + create: _deepseek_ai_dsh_api_session_controller_session_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionListValue', + create: _deepseek_ai_dsh_api_session_controller_session_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":259,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/modelCatalog', + service: 'sessionController', + namespace: 'session', + method: 'modelCatalog', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#ModelCatalog', + create: _deepseek_ai_dsh_api_session_controller_session_modelCatalog_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":317,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/openWorkspacePath', + service: 'sessionController', + namespace: 'session', + method: 'openWorkspacePath', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionOpenWorkspacePathRequest', + create: _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionOpenWorkspacePathValue', + create: _deepseek_ai_dsh_api_session_controller_session_openWorkspacePath_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":347,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/page', + service: 'sessionController', + namespace: 'session', + method: 'page', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPageRequest', + create: _deepseek_ai_dsh_api_session_controller_session_page_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPage', + create: _deepseek_ai_dsh_api_session_controller_session_page_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":475,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/projections', + service: 'sessionController', + namespace: 'session', + method: 'projections', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionProjectionsRequest', + create: _deepseek_ai_dsh_api_session_controller_session_projections_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionProjectionsValue', + create: _deepseek_ai_dsh_api_session_controller_session_projections_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":498,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/prompt', + service: 'sessionController', + namespace: 'session', + method: 'prompt', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPromptRequest', + create: _deepseek_ai_dsh_api_session_controller_session_prompt_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionPromptValue', + create: _deepseek_ai_dsh_api_session_controller_session_prompt_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":433,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/rename', + service: 'sessionController', + namespace: 'session', + method: 'rename', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionRenameRequest', + create: _deepseek_ai_dsh_api_session_controller_session_rename_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionRenameValue', + create: _deepseek_ai_dsh_api_session_controller_session_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":410,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/search', + service: 'sessionController', + namespace: 'session', + method: 'search', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSearchRequest', + create: _deepseek_ai_dsh_api_session_controller_session_search_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSearchValue', + create: _deepseek_ai_dsh_api_session_controller_session_search_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":270,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/selectModel', + service: 'sessionController', + namespace: 'session', + method: 'selectModel', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSelectModelRequest', + create: _deepseek_ai_dsh_api_session_controller_session_selectModel_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionSelectModelValue', + create: _deepseek_ai_dsh_api_session_controller_session_selectModel_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":290,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/updateQueue', + service: 'sessionController', + namespace: 'session', + method: 'updateQueue', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionUpdateQueueRequest', + create: _deepseek_ai_dsh_api_session_controller_session_updateQueue_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SessionUpdateQueueValue', + create: _deepseek_ai_dsh_api_session_controller_session_updateQueue_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":454,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications', + service: 'sessionController', + namespace: 'session', + method: 'workspacePathApplications', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications:request', + create: _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller#session/workspacePathApplications:result', + create: _deepseek_ai_dsh_api_session_controller_session_workspacePathApplications_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/index.ts","line":377,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-session-controller#skills/list', + service: 'sessionSkillCatalog', + namespace: 'skills', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SkillListRequest', + create: _deepseek_ai_dsh_api_session_controller_skills_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-session-controller/types#SkillListValue', + create: _deepseek_ai_dsh_api_session_controller_skills_list_result$schema, + }, + sourceLocation: {"file":"packages/api/session-controller/src/skill-catalog.ts","line":35,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/session-controller/package.json b/apps/web-ui/packages/api/session-controller/package.json new file mode 100644 index 000000000..eb38e466e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/package.json @@ -0,0 +1,59 @@ +{ + "name": "@deepseek-ai/dsh-api-session-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./remote-events": { + "types": "./src/remote-events.ts", + "default": "./src/remote-events.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "*", + "@deepseek-ai/dsh-client-store": "*", + "@deepseek-ai/dsh-session": "*", + "@deepseek-ai/dsh-util-workspace-path": "*", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-api-gateway": "*", + "@deepseek-ai/dsh-util-values": "*", + "@deepseek-ai/dsh-util-crypto": "*", + "@deepseek-ai/dsh-llm": "*", + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-file-upload" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/agent.ts b/apps/web-ui/packages/api/session-controller/src/agent.ts new file mode 100644 index 000000000..94bffb7fc --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/agent.ts @@ -0,0 +1,571 @@ +/** Agent activation, composition, and model-selection policy owned by API Session. */ + +import { mkdir } from "node:fs/promises"; +import type { Context } from "@deepseek-ai/cordis"; +import { installModelSelection } from "@deepseek-ai/dsh-agent"; +import type { + Agent, + AgentOptions, + AgentSetup, + ModelSelection as AgentModelSelection, + ModelSelectionRef, +} from "@deepseek-ai/dsh-agent"; +import type {} from "@deepseek-ai/dsh-agent-default-model"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry"; +import { ReasoningEffortId } from "@deepseek-ai/dsh-llm"; +import type { Session, SessionId } from "@deepseek-ai/dsh-session"; +import type { SessionInspection } from "@deepseek-ai/dsh-session-persistence"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type {} from "@deepseek-ai/dsh-typert-registry"; +import type { ModelSelection } from "./types.ts"; + +/** Cold Session identity absent from persistence. */ +export class ApiSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiSessionSubagentOwnership extends Error { + /** @param sessionId - identity reserved to subagent routing. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`); + } +} + +/** Explicit-id creation attempted to adopt a Session under another cwd. */ +export class ApiSessionCwdConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedCwd: string, + readonly existingCwd: string | undefined, + ) { + super( + existingCwd === undefined + ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"` + : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`, + ); + } +} + +/** Explicit-id creation attempted to adopt a Session under another preset. */ +export class ApiSessionPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` + : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, + ); + } +} + +/** Failures produced while resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentError = RemoteError< + "session/not-found" | "session/agent-busy" | "session/writer-held" | "gateway/internal" +>; + +/** Result of resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentResult = + { readonly agent: Agent } | { readonly error: ApiSessionAgentError }; + +type InstalledSelection = ModelSelectionRef & { + current: AgentModelSelection; + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean; +}; + +/** + * Test whether generic Session routing must leave an identity to subagent routing. + * @param ctx - Host context carrying the Agent ownership registry. + * @param session - attached or live Session whose ownership is tested. + * @param agent - live Agent when one exists for the Session. + * @returns whether subagent routing owns the Session identity. + */ +export function hasApiSessionSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === "subagent") return true; + const parentId = session.header.parentSession; + if (parentId === undefined || agent === undefined) return false; + const parent = ctx.agents.get(parentId); + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent); +} + +/** + * Build the stable caller-facing subagent ownership rejection. + * @param sessionId - Session identity owned by subagent routing. + * @returns a stable Session-domain failure. + */ +export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { + return new RemoteError( + "session/agent-busy", + `session "${sessionId}" is owned by subagent routing`, + { reason: "use subagent delivery for this child session" }, + ); +} + +/** + * Inspect one cold Session without repairing, resuming, or publishing it. + * @param ctx - Host context carrying Session persistence. + * @param sessionId - durable Session identity. + * @param signal - optional cancellation for persistence reads. + * @returns the persisted header and complete event prefix. + */ +export async function inspectApiSession( + ctx: Context, + sessionId: SessionId, + signal?: AbortSignal, +): Promise { + try { + using observation = await ctx.sessionQuery.observeSession(sessionId, { + ...(signal === undefined ? {} : { signal }), + projectionMode: "none", + }); + if (observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + return { + meta: observation.header, + inheritedEventCount: observation.inheritedEventCount, + events: [...observation.events], + }; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + throw error; + } +} + +/** Owns every operation that may create, resume, or configure a Web Agent. */ +export class ApiSessionAgentController { + private readonly resumes = new Map>(); + private readonly creations = new Map>(); + private readonly selections = new WeakMap(); + private readonly imageAdmissionChains = new WeakMap>(); + + /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ + constructor(private readonly ctx: Context) { + ctx.typert.lookups.configure("agent", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent; + }); + ctx.typert.lookups.configure("session", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent.session; + }); + ctx.typert.contexts.configureHost("agent", async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent.ctx; + }); + } + + /** + * Resolve or resume one ordinary Session, deduplicating concurrent resumes. + * @param sessionId - ordinary Session identity. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveAgent(sessionId: SessionId): Promise { + return this.resolve(sessionId); + } + + /** + * Resolve one ordinary Session from an already-retained exact observation. + * @param observation - Host-owned observation whose preparation stays pinned through setup. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveObservedAgent(observation: SessionObservation): Promise { + return this.resolve(observation.header.id, observation); + } + + private async resolve( + sessionId: SessionId, + observation?: SessionObservation, + ): Promise { + const live = this.liveAgent(sessionId); + if (live !== undefined) return live; + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) }; + } + + let resume = this.resumes.get(sessionId); + if (resume === undefined) { + resume = this.resume(sessionId, observation).finally(() => { + this.resumes.delete(sessionId); + }); + this.resumes.set(sessionId, resume); + } + try { + const agent = await resume; + // A shared resume can publish an identity that subagent routing adopts + // before every waiter observes it; apply the live ownership policy again. + const published = this.liveAgent(sessionId); + return published ?? { agent }; + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return { error: new RemoteError("session/not-found", error.message, { sessionId }) }; + } + if (error instanceof ApiSessionSubagentOwnership) { + return { error: apiSessionSubagentOwnershipError(error.sessionId) }; + } + const raced = this.liveAgent(sessionId); + if (raced !== undefined) return raced; + const racedSession = this.ctx.sessions.get(sessionId); + if ( + racedSession !== undefined && + hasApiSessionSubagentOwner(this.ctx, racedSession, undefined) + ) { + return { error: apiSessionSubagentOwnershipError(sessionId) }; + } + if (error instanceof Error && error.name === "SessionAlreadyOwnedError") { + return { error: new RemoteError("session/writer-held", error.message, { sessionId }) }; + } + return { + error: new RemoteError( + "gateway/internal", + `resume failed for session "${sessionId}": ${String(error)}`, + {}, + ), + }; + } + } + + /** + * Resolve one requested identity, creating or resuming it once. + * @param sessionId - requested Session identity. + * @param cwd - directory the Session must own. + * @param checkPersistedIdentity - whether to inspect a cold identity before creation. + * @param presetId - optional Agent preset the Session must own. + * @returns the matching live ordinary Agent. + */ + async ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { + let creation = this.creations.get(sessionId); + if (creation === undefined) { + creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) + .catch((error: unknown) => { + const live = this.ctx.agents.get(sessionId); + if (live !== undefined) { + if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + return live; + } + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + throw error; + }) + .finally(() => { + this.creations.delete(sessionId); + }); + this.creations.set(sessionId, creation); + } + const agent = await creation; + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (presetId !== undefined) { + this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)); + } + if (agent.session.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd); + } + return agent; + } + + /** + * Install or return the Session-local model selection used by prompt assembly. + * @param agent - live Agent that owns the selection. + * @returns the installed mutable selection reference. + */ + selectionFor(agent: Agent): InstalledSelection { + const installed = this.selections.get(agent); + if (installed !== undefined) return installed; + const projectionState = this.ctx.sessionProjections.stateOf(agent.session, "modelSelection"); + if (projectionState === undefined) { + throw new Error("api-session: required modelSelection projection is not registered"); + } + let picked = + projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending); + const defaultModel = this.ctx.agentDefaultModel; + const selection: InstalledSelection = { + get current(): AgentModelSelection { + if (picked !== undefined) return picked; + const loggedHeader = agent.session.requestHeader(); + if (loggedHeader === undefined) return defaultModel.currentSelection(); + const logged = loggedHeader.config; + return { + provider: logged.provider, + model: logged.model, + // An effort the adapter defaulted is not a conversation choice: restoring + // it as one would make an unchanged default read as a request change. + ...(logged.reasoningEffort === undefined || + loggedHeader.adapterDefaults?.reasoningEffort === true + ? {} + : { reasoningEffort: logged.reasoningEffort }), + }; + }, + set current(next: AgentModelSelection) { + picked = next; + }, + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean { + if ( + picked?.provider !== provider || + picked.model !== model || + picked.reasoningEffort !== reasoningEffort + ) + return false; + picked = undefined; + return true; + }, + assembled: undefined, + }; + installModelSelection(agent.ctx, selection); + this.selections.set(agent, selection); + return selection; + } + + /** + * Commit and cache one validated selection for the next prompt assembly. + * @param agent - live Agent that owns the selection. + * @param selection - validated selection to record and apply. + */ + selectForNextRequest(agent: Agent, selection: AgentModelSelection): void { + agent.session.append("model/selection", selection); + this.selectionFor(agent).current = selection; + } + + /** + * Let a matching durable request header retire the execution cache. + * @param agent - live Agent whose request was recorded. + * @param provider - provider route used by the request. + * @param model - provider-owned model used by the request. + * @param reasoningEffort - adapter-owned effort used by the request. + * @returns whether the pending selection was consumed. + */ + consumeSelection( + agent: Agent, + provider: string, + model: string, + reasoningEffort: string | undefined, + ): boolean { + return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false; + } + + /** + * Read the current Agent preset from the Session projection. + * @param session - live Session whose projection state is available. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForSession(session: Session): string | undefined { + return this.ctx.sessionProjections.stateOf(session, "agentPreset") ?? undefined; + } + + /** + * Serialize image admission and model selection for one Agent. + * @param agent - live Agent that owns the serialization chain. + * @param operation - asynchronous operation admitted after prior work settles. + * @returns the operation result or rejection. + */ + serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation); + this.imageAdmissionChains.set( + agent, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; + } + + /** + * Resolve the preset id and pre-publication Agent setup for a create or resume. + * @param presetId - requested preset or the configured default when omitted. + * @returns the resolved preset identity and Agent setup callback. + */ + async composeAgent(presetId: string | undefined): Promise<{ + readonly agentPreset?: string; + readonly setup: AgentSetup; + }> { + const presets = this.ctx.get("agentPresets"); + if (presets === undefined) { + return { + setup: (_agentCtx, agent) => { + this.installSelection(agent); + }, + }; + } + const resolvedId = (await presets.resolve(presetId)).id; + return { + agentPreset: resolvedId, + setup: async (agentCtx, agent) => { + this.installSelection(agent); + await presets.mount(agentCtx, resolvedId); + }, + }; + } + + private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { + const agent = this.ctx.agents.get(sessionId); + if (agent === undefined) return undefined; + return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + ? { error: apiSessionSubagentOwnershipError(sessionId) } + : { agent }; + } + + private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise { + if (supplied !== undefined) return this.resumeObserved(sessionId, supplied); + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + return await this.resumeObserved(sessionId, observation); + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + throw error; + } + } + + private async resumeObserved( + sessionId: SessionId, + observation: SessionObservation, + ): Promise { + if (observation.header.id !== sessionId || observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`); + } + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + const composition = await this.composeAgent(this.presetForObservation(observation)); + const published = this.ctx.sessions.get(sessionId); + const live = this.ctx.agents.get(sessionId); + if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + return ( + await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + }) + ).agent; + } + + private async createOrAdopt( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId: string | undefined, + ): Promise { + const attached = this.ctx.sessions.get(sessionId); + const live = this.ctx.agents.get(sessionId); + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (live !== undefined) return live; + + if (checkPersistedIdentity) { + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId); + } + if (observation.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd); + } + const storedPreset = this.presetForObservation(observation); + this.assertPresetUnchanged(sessionId, presetId, storedPreset); + const composition = await this.composeAgent(storedPreset); + return ( + await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + }) + ).agent; + } catch (error: unknown) { + if ( + !(error instanceof SessionQueryError) || + error.code !== "SESSION_QUERY_SESSION_NOT_FOUND" + ) + throw error; + } + } + + try { + await mkdir(cwd, { recursive: true }); + } catch (error: unknown) { + throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { + cause: error, + }); + } + const composition = await this.composeAgent(presetId); + return ( + await this.ctx.agents.create({ + sessionId, + agentOptions: this.agentOptions(), + meta: { + cwd, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + setup: composition.setup, + }) + ).agent; + } + + private agentOptions(): AgentOptions { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection(); + return { provider, model }; + } + + private installSelection(agent: Agent): void { + this.selectionFor(agent); + } + + /** + * Read the current Agent preset from an all-projections observation. + * @param observation - exact Session observation carrying its projection snapshot. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForObservation(observation: SessionObservation): string | undefined { + if (observation.projections === undefined) { + throw new Error("api-session: Agent activation requires a projected Session observation"); + } + return observation.projections.values.agentPreset ?? undefined; + } + + private assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return; + throw new ApiSessionPresetConflict(sessionId, requested, existing); + } +} + +function agentModelSelection(selection: ModelSelection): AgentModelSelection { + return { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }), + }; +} diff --git a/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts b/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts new file mode 100644 index 000000000..a76799770 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/archived-session-gate.ts @@ -0,0 +1,57 @@ +/** + * The controller's admission gate for archived Sessions: an archived + * Session, or a subagent descendant of one, must not run a model step until + * it is restored. The work a Session still runs is reported and stopped by + * its owners — the Agent registry (`turn`), the job registry seam (`job`), + * the Subagent runtime (`subagent`), and the Schedule plugin (`schedule`) — + * through the Workspace registry's archive-admission events. + */ + +import type { Context, Plugin } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-workspace"; + +/** + * The gate as a plugin for `ctx.plugin(...)`: it loads once the Agent + * registry, Session store, and Workspace registry are available and unwinds + * with the plugin's fiber. A late waking delivery to an archived Session — a + * subagent settlement, a queued follow-up — proposes a step the gate rejects, + * which the loop ends as `blocked` without a request; unarchiving lifts the + * gate for the whole lineage. + */ +export const ArchivedSessionGate: Plugin.Object = { + name: "archived-session-gate", + inject: ["agents", "sessions", "workspaceRegistry"], + apply(ctx: Context): void { + ctx.on("agent/pre-step", (payload, next) => + underArchivedSession(ctx, payload.agent) + ? Promise.resolve({ kind: "reject" as const }) + : next(), + ); + }, +}; + +/** + * Whether the Agent's Session, or a Session above it in its subagent lineage, + * is archived. Lineage follows the durable header fields through + * subagent-origin Sessions only: a fork of an archived Session is an + * independent conversation. + * @param ctx - Host context. + * @param agent - the Agent proposing a step. + * @returns whether an archived Session owns the step. + */ +export function underArchivedSession(ctx: Context, agent: Agent): boolean { + const archived = ctx.workspaceRegistry.archivedSessionIds; + let header = agent.session.header; + const visited = new Set(); + while (!visited.has(header.id)) { + if (archived.includes(header.id)) return true; + visited.add(header.id); + if (header.origin !== "subagent" || header.parentSession === undefined) return false; + const parent = ctx.sessions.get(header.parentSession); + if (parent === undefined) return archived.includes(header.parentSession); + header = parent.header; + } + return false; +} diff --git a/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts b/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts new file mode 100644 index 000000000..35d0a6bcd --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/assistant-stream.ts @@ -0,0 +1,103 @@ +/** Process-local assistant state retained for reconnecting Web followers. */ + +import type { AssistantStreamFrame } from "@deepseek-ai/dsh-agent"; +import { AssistantStreamAccumulator } from "@deepseek-ai/dsh-llm"; +import type { SessionSeqCursor } from "@deepseek-ai/dsh-session"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { SessionAssistantStreamAttempt, SessionAssistantStreamBaseline } from "./types.ts"; + +interface MutableAttempt { + readonly attemptId: SessionAssistantStreamAttempt["attemptId"]; + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + readonly stream: AssistantStreamAccumulator; + nextIndex: number; +} + +const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0 }; + +/** + * Folds dense Agent frames and materializes one shared immutable reconnect + * baseline per accepted revision. + */ +export class SessionAssistantStreamAccumulator { + private activeAttempt: MutableAttempt | undefined; + private revision = 0; + private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE; + private dirty = false; + + /** + * Fold one trusted frame from the current attached Agent lifecycle. + * @param frame - next dense process-local Assistant frame. + * @param durableCursor - last committed Session seq when this frame was observed. + */ + accept(frame: AssistantStreamFrame, durableCursor: SessionSeqCursor): void { + if (frame.type === "start" && frame.revision === 1 && this.revision !== 0) { + this.activeAttempt = undefined; + this.revision = 0; + } + if (frame.revision !== this.revision + 1) { + this.activeAttempt = undefined; + this.revision = frame.revision; + this.dirty = true; + return; + } + this.revision = frame.revision; + switch (frame.type) { + case "start": + this.activeAttempt = { + attemptId: frame.attemptId, + startedAfterSeq: durableCursor, + turn: frame.turn, + step: frame.step, + stream: new AssistantStreamAccumulator(), + nextIndex: 0, + }; + break; + case "chunk": { + const attempt = this.activeAttempt; + if ( + attempt === undefined || + attempt.attemptId !== frame.attemptId || + frame.index !== attempt.nextIndex + ) { + this.activeAttempt = undefined; + break; + } + attempt.stream.push({ time: frame.time, chunk: frame.chunk }); + attempt.nextIndex += 1; + break; + } + case "end": + this.activeAttempt = undefined; + break; + } + this.dirty = true; + } + + /** + * Read the cached reconnect baseline, materializing it after a state change. + * @returns the identity-stable baseline for the latest accepted revision. + */ + snapshot(): SessionAssistantStreamBaseline { + if (!this.dirty) return this.snapshotValue; + this.snapshotValue = { + revision: this.revision, + ...(this.activeAttempt === undefined + ? {} + : { + activeAttempt: { + attemptId: this.activeAttempt.attemptId, + startedAfterSeq: this.activeAttempt.startedAfterSeq, + turn: this.activeAttempt.turn, + step: this.activeAttempt.step, + nextIndex: this.activeAttempt.nextIndex, + stream: this.activeAttempt.stream.snapshot() as unknown as readonly JsonValue[], + }, + }), + }; + this.dirty = false; + return this.snapshotValue; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/catalog.ts b/apps/web-ui/packages/api/session-controller/src/catalog.ts new file mode 100644 index 000000000..25c0ae538 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/catalog.ts @@ -0,0 +1,132 @@ +/** Shared projection of the live LLM registry into the browser model catalog. */ + +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import type {} from "@deepseek-ai/dsh-settings"; +import type { LlmModelInfo } from "@deepseek-ai/dsh-llm"; +import type { Context } from "@deepseek-ai/cordis"; +import type { ModelCatalog, ModelReasoning, ModelSelection } from "./types.ts"; + +/** + * Build the browser model catalog without requiring a Session. + * @param ctx - Host context carrying the live LLM registry. + * @param defaultSelection - deployment default used before a Session selects a model. + * @returns successful non-empty provider groups and isolated provider failures. + */ +export async function buildModelCatalog( + ctx: Context, + defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(), +): Promise { + const providers = ctx.llm.listProviders(); + const catalog = await Promise.all( + providers.map(async (provider) => { + try { + const models = await ctx.llm.listModels(provider.id); + const entries = await Promise.all( + models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id); + const reasoning: ModelReasoning | undefined = + resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map((effort) => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined + ? {} + : { description: effort.description }), + })), + ...(resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }), + }; + return { + id: model.id, + name: model.name, + ...(model.description === undefined ? {} : { description: model.description }), + ...(reasoning === undefined ? {} : { reasoning }), + }; + }), + ); + return { + kind: "group" as const, + group: { id: provider.id, name: provider.name, models: entries }, + }; + } catch (error) { + return { + kind: "failure" as const, + failure: { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + }, + }; + } + }), + ); + const groups = catalog + .flatMap((item) => (item.kind === "group" ? [item.group] : [])) + .filter((group) => group.models.length > 0); + return { + default: { ...defaultSelection }, + routableProviders: groups.map((group) => group.id), + groups, + failures: catalog.flatMap((item) => (item.kind === "failure" ? [item.failure] : [])), + }; +} + +/** + * Check a GUI selection against the current available provider catalog. + * @param ctx - Host LLM registry. + * @param selection - stored or explicitly requested selection. + * @returns whether the exact model is currently advertised as available. + */ +export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { + if (!ctx.llm.listProviders().some((provider) => provider.id === selection.provider)) return false; + let models: readonly LlmModelInfo[]; + try { + models = await ctx.llm.listModels(selection.provider); + } catch (error) { + throw new RemoteError( + "session/model-unavailable", + error instanceof Error ? error.message : String(error), + { provider: selection.provider, model: selection.model }, + ); + } + return models.some((model) => model.id === selection.model); +} + +/** + * Check configured provider API-key references independently of model availability. + * @param ctx - Host registry, settings, and credential services. + * @returns whether any API-key provider has a configured credential. + */ +export async function hasProviderApiKey(ctx: Context): Promise { + const settings = ctx.get("settings"); + const credentials = ctx.get("credentials"); + if (settings === undefined || credentials === undefined) { + throw new RemoteError( + "session/provider-credentials-unavailable", + "provider credentials are unavailable", + {}, + ); + } + const namespaces = settings.describe({ redactSecrets: true }); + for (const provider of ctx.llm.listConfigurableProviders()) { + if (provider.provider === "deepseek-account") continue; + let profile = namespaces.find((namespace) => namespace.ns === provider.settingsNs)?.value; + for (const key of provider.settingsPath) { + profile = + typeof profile === "object" && profile !== null ? Reflect.get(profile, key) : undefined; + } + if (typeof profile !== "object" || profile === null) continue; + const ref: unknown = Reflect.get(profile, "apiKeyEnv"); + if ( + typeof ref === "string" && + ref.length > 0 && + (await credentials.describe(credentialRef(ref))).configured + ) + return true; + } + return false; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts new file mode 100644 index 000000000..a7176a068 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/events.ts @@ -0,0 +1,207 @@ +/** Observable contiguous Session event window consumed by domain assemblers. */ +import { notifySubscribers, type ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { LlmAttemptId, StreamChunk } from "@deepseek-ai/dsh-llm"; +import type { SessionEvent } from "@deepseek-ai/dsh-session/types"; + +/** Client-only live chunk presentation; `seq` orders the transient row between durable Session seqs. */ +export interface AssistantLiveChunkEvent { + readonly type: "assistant/live-chunk"; + readonly seq: number; + readonly time: number; + readonly data: { + readonly attemptId: LlmAttemptId; + readonly turn: number; + readonly step: number; + readonly chunk: StreamChunk; + }; +} + +/** Current durable Session event or one client-only live chunk presentation. */ +export type SessionEventLike = SessionEvent | AssistantLiveChunkEvent; + +/** Client history entry retaining its coarse transport discriminator. */ +export type SessionEventLikeEntry = + | { readonly type: "event"; readonly event: SessionEvent } + | { readonly type: "transient"; readonly event: AssistantLiveChunkEvent }; + +/** Scalar live entry accepted by append-only Client paths. */ +export type SessionLiveEventEntry = Extract; +/** Durable Assistant event that atomically supersedes one attempt's transient rows. */ +export interface SessionAssistantSettlementEntry { + readonly type: "event"; + readonly event: SessionEvent<"assistant/message"> | SessionEvent<"assistant/attempt">; +} +/** Client-only Assistant frame admitted outside durable cursor algebra. */ +export type SessionTransientEventEntry = Extract< + SessionEventLikeEntry, + { readonly type: "transient" } +>; + +interface EventWindowLeaf { + readonly kind: "leaf"; + readonly entries: readonly SessionEventLikeEntry[]; + readonly length: number; +} + +interface EventWindowConcat { + readonly kind: "concat"; + readonly left: EventWindowNode; + readonly right: EventWindowNode; + readonly length: number; +} + +type EventWindowNode = EventWindowLeaf | EventWindowConcat; + +function leaf(entries: readonly SessionEventLikeEntry[]): EventWindowLeaf { + return { kind: "leaf", entries, length: entries.length }; +} + +function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat { + return { kind: "concat", left, right, length: left.length + right.length }; +} + +function materialize(node: EventWindowNode): readonly SessionEventLikeEntry[] { + if (node.kind === "leaf") return node.entries; + const entries = new Array(node.length); + const pending: EventWindowNode[] = [node]; + let index = 0; + while (pending.length > 0) { + const current = pending.pop() as EventWindowNode; + if (current.kind === "concat") { + pending.push(current.right, current.left); + continue; + } + for (const entry of current.entries) { + entries[index] = entry; + index += 1; + } + } + return entries; +} + +function windowSnapshot( + node: EventWindowNode, + hasMore: boolean, + revision: number, + change: SessionEventChange, +): SessionEventWindow { + let entries: readonly SessionEventLikeEntry[] | undefined; + return { + get entries() { + entries ??= materialize(node); + return entries; + }, + hasMore, + revision, + change, + }; +} + +/** Exact delta that produced the latest event-window revision. */ +export type SessionEventChange = + | { readonly kind: "replace"; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: "prepend"; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: "append"; readonly entries: readonly SessionEventLikeEntry[] } + | { + readonly kind: "settle-assistant"; + readonly attemptId: LlmAttemptId; + readonly entry?: SessionAssistantSettlementEntry; + }; + +/** Current contiguous event window and its latest synchronous delta. */ +export interface SessionEventWindow { + readonly entries: readonly SessionEventLikeEntry[]; + readonly hasMore: boolean; + readonly revision: number; + readonly change: SessionEventChange; +} + +/** Conversation-facing event source exposed by one Session binding. */ +export type SessionEventSource = ObservableSnapshot; + +/** Session-owned event feed; every accepted window mutation publishes synchronously. */ +export class MutableSessionEventSource implements SessionEventSource { + private readonly listeners = new Set<() => void>(); + private window: EventWindowNode = leaf([]); + private snapshot: SessionEventWindow = windowSnapshot(this.window, false, 0, { + kind: "replace", + entries: [], + }); + + /** @returns the cached event-window snapshot. */ + getSnapshot(): SessionEventWindow { + return this.snapshot; + } + + /** + * Subscribe to synchronous window publication. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Replace the complete contiguous window. + * @param entries - complete window. + * @param hasMore - whether older history remains. + */ + replace(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = leaf(entries); + this.publish(hasMore, { kind: "replace", entries }); + } + + /** + * Prepend one older contiguous page. + * @param entries - newly loaded older entries. + * @param hasMore - whether still older history remains. + */ + prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = concat(leaf(entries), this.window); + this.publish(hasMore, { kind: "prepend", entries }); + } + + /** + * Append one contiguous live entry. + * @param entry - live tail entry. + */ + append(entry: SessionEventLikeEntry): void { + const entries = [entry]; + this.window = concat(this.window, leaf(entries)); + this.publish(this.snapshot.hasMore, { + kind: "append", + entries, + }); + } + + /** + * Replace one attempt's transient rows with its committed durable settlement. + * @param attemptId - process-local attempt whose live rows are now redundant. + * @param entry - durable settlement committed for that attempt. + */ + settleAssistant(attemptId: LlmAttemptId, entry?: SessionAssistantSettlementEntry): void { + const entries = materialize(this.window).filter( + (candidate) => candidate.type !== "transient" || candidate.event.data.attemptId !== attemptId, + ); + if (entry !== undefined) { + const index = entries.findIndex((candidate) => candidate.event.seq > entry.event.seq); + if (index < 0) entries.push(entry); + else entries.splice(index, 0, entry); + } + this.window = leaf(entries); + this.publish(this.snapshot.hasMore, { + kind: "settle-assistant", + attemptId, + ...(entry === undefined ? {} : { entry }), + }); + } + + private publish(hasMore: boolean, change: SessionEventChange): void { + this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change); + notifySubscribers(this.listeners, "[session-controller] event feed"); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts new file mode 100644 index 000000000..2caeccc26 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts @@ -0,0 +1,159 @@ +/** + * The outward session face. Feature packages never see the concrete Session + * class: components read lifecycle state through `useSession` (the + * ObservableSnapshot half), and orchestration code calls the behavior verbs + * below — nothing else. Widening this interface is the explicit act of + * widening what features may do to a session (and what every test fixture + * must stub); implementation-internal entry points (history staging, wire-frame + * dispatch) stay on the class, invisible out here. + */ +import type { + AttachmentIdType, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { SessionId, SessionSeq } from "@deepseek-ai/dsh-session/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { PromptContentPart, QueueAction, SessionRequestId } from "../../types.ts"; +import type { PendingSubmissionAttachment, SessionSnapshot } from "./snapshot.ts"; + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * attachment references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { + readonly reason: "observed"; + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + } + | { readonly reason: "failed" }; + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Delivery mode used with the upcoming prompt. */ + readonly mode: "queue" | "steer"; + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string; + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[]; + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void; +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId; + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void; +} + +/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ +export interface ProjectionsFace { + /** + * The identity-stable bare observable for one projection key (absence is + * an `undefined` snapshot, never a missing face). + * @param key - projection key. + * @returns the key's value face. + */ + faceOf(key: string): ObservableSnapshot; +} + +/** Identity plus the behavior verbs features may invoke on a session. */ +export interface ISession { + /** The session's host identity (agent id — same axis). */ + readonly sessionId: SessionId; + /** Host-computed projection values by key (the useProjection seat). */ + readonly projections: ProjectionsFace; + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * Chat echoes persist until durable admission; transcript identities also + * wait for the Inbox claim watermark so stale queue rows stay suppressed. + * Queued echoes retire on queue acceptance. Identified failures retire + * submissions that have not already reached durable admission. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle; + /** + * Send a prompt into the session. + * @param content - text plus browser-owned temporary image uploads. + * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. + * @returns acceptance, or the business error (also mirrored into snapshot.promptError). + */ + prompt( + content: PromptContentPart[], + mode: "queue" | "steer", + signal?: AbortSignal, + requestId?: SessionRequestId, + ): Promise>; + /** + * Resolve one durable image referenced by this session. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + readAttachment( + attachmentId: AttachmentIdType, + ): Promise>; + /** + * Apply one edit, remove, or Steer action to a still-pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - requested queue operation. + * @returns acceptance, or a business/transport error. + */ + updateQueue(itemId: MessageId, action: QueueAction): Promise>; + /** + * Cancel the running turn. Pending queued work remains and resumes in FIFO + * order after the Host reaches cancellation quiescence. + * @returns acceptance, or the business error. + */ + cancel(): Promise>; + /** + * Rename this session (explicit user title; pins it against automatic + * regeneration). + * @param title - raw title text (the host normalizes acceptance). + * @returns the normalized accepted title and its event seq, or the business error. + */ + rename(title: string): Promise>; + /** + * Extend history by at least 50 messages and two Turn starts, including a + * partial Turn at the window's beginning. Stop at 500 messages or history + * exhaustion even when those minima cannot be met. Publish one prepend. + * @returns completion; failures land in snapshot.openState/loadingOlder. + */ + loadOlder(): Promise; + /** + * Page history backwards until the window covers `seq` (inclusive) — the + * turn-jump loader. Repeated calls while a jump is paging lower its shared + * target and return the in-flight completion; `snapshot.loadingOlder` is + * the busy signal for the whole jump. + * Each page uses the same Turn alignment as loadOlder, with a 200-message + * minimum. The 500-message limit applies per page, not to the whole jump. + * Older pages publish together at completion, including successful pages + * before a later failure; live events remain independently visible. + * @param seq - durable event seq the window must reach (a turn's `turn/start` seq). + * @returns completion once covered, exhausted, superseded, or failed soft. + */ + loadThrough(seq: SessionSeq): Promise; + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the Remote face's error branch. + */ + command(line: string): Promise>; +} + +/** + * The full outward face: behavior verbs plus the Session lifecycle read side + * (the `useSession` hook source). This is the type carried by + * `SessionBinding.session` and the provide channel. + */ +export type SessionFace = ISession & ObservableSnapshot; diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts new file mode 100644 index 000000000..9daf7030f --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/sessions.ts @@ -0,0 +1,167 @@ +/** + * The outward sessions-service face — what `ctx.sessions` exposes to feature + * packages. Transport entry points and implementation internals stay on + * the concrete class. Widening this interface is the + * explicit act of widening what features may do to the sessions domain. + */ +import type { Context } from "@deepseek-ai/cordis"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { AgentContext } from "../scope.ts"; +import type { SessionSearchResultItem } from "../sessions/manager.ts"; +import type { SessionBinding, SessionListState } from "../sessions/service.ts"; +import type { SessionFace } from "./session.ts"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import type { SessionReferenceSource } from "../index.ts"; + +export type { AgentContext } from "../scope.ts"; + +/** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */ +export type SessionTarget = SessionId | SubagentAddress; + +/** One independent use of an exact Client generation, without Host Agent ownership. */ +export interface SessionReference extends Disposable { + readonly sessionId: SessionId; + /** Shared binding; access fails after reference release or generation disposal. */ + readonly binding: SessionBinding; + /** This reference's cancellable wait for the shared initial `Session.open()` attempt to settle. */ + readonly ready: Promise; + /** Release once; the final reference starts local scope and history teardown. */ + release(): void; +} + +/** Consumer identity and optional cancellation of one acquisition waiter. */ +export interface SessionRetainOptions { + readonly source: SessionReferenceSource; + readonly signal?: AbortSignal | undefined; +} + +/** Local ownership counts, independent of catalog membership and never persisted. */ +export interface SessionRetainInfo { + readonly referenceCount: number; + /** Positive source counts only; a source without references is absent. */ + readonly retainedBy: Readonly>>; +} + +/** The sessions-service face injected as `ctx.sessions`. */ +export interface ISessions { + /** Host catalog and local reference-source counts; navigation belongs to view owners. */ + readonly list: ObservableSnapshot; + /** + * Retain an exact Client generation and start its shared initial history opening. + * @param target - known identity or durable direct-parent address. + * @param options - required consumer source and optional independent waiter cancellation. + * @returns an owned reference immediately; await `reference.ready` when the initial open attempt must settle first. + */ + retain(target: SessionTarget, options: SessionRetainOptions): SessionReference; + /** + * Hold one reference through callback settlement, including synchronous and asynchronous failures. + * @param target - Session to acquire. + * @param options - source and acquisition cancellation. + * @param operation - callback using the reference only until its returned value or Promise settles. + * @returns the callback result after release; acquisition and callback failures propagate unchanged. + */ + using( + target: SessionTarget, + options: SessionRetainOptions, + operation: (reference: SessionReference) => T | Promise, + ): Promise; + /** + * Observe local reference counts without retaining, creating a scope, or opening history. + * The returned source keeps stable identity across same-id generations and remains allocated + * until the Client root is disposed, even after its final subscriber leaves. + * @param id - explicit Session identity; Host existence is not implied. + * @returns a stable read-only source across same-id generations, with zero counts when none is live. + */ + retainInfo(id: SessionId): ObservableSnapshot; + /** + * The `session.search` result bound the wire schema fixes, exposed to + * presentation as injected data. Not per-connection state: every transport + * (fixture included) reports the same number. + */ + readonly searchResultLimit: number; + /** + * Create or adopt a Session on the Host. + * @param opts - target workspace, directory, and optional preallocated identity. + * @returns the catalogued identity; retain it before borrowing its binding. + */ + create(opts?: { + workspaceId?: WorkspaceId; + cwd?: string; + sessionId?: SessionId; + }): Promise; + /** + * Resolve an already discovered direct-parent address without opening it. + * @param id - possible addressed child id. + * @returns a retained or loaded-catalog address, without retaining a new selection or scope. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined; + + /** + * Load all Session projections once per connection; retry an unsuccessful initial read. + * @param sessionId - Session to inspect without opening its conversation. + * @returns completion of the current or newly started refresh. + */ + refreshProjections(sessionId: SessionId): Promise; + + /** + * Refresh the Host-authoritative Session list. + * @returns completion of the current or newly started Session-list refresh. + */ + refresh(): Promise; + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results, or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise>; + /** + * Fork a session from an exact inclusive prefix of the source; on + * resolution the child is catalogued and can be explicitly retained. + * @param opts - source session id, the optional exact inclusive boundary + * seq (a real event seq the caller already knows; a cut inside an open + * turn is balanced Host-side with synthetic closers, and omission selects + * the latest completed-turn prefix), and whether to increment an + * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename. + * @returns the child session id. + * @throws when the fork fails, or when a requested child-title rename fails after creation. + */ + fork(opts: { + sessionId: SessionId; + atSeq?: number; + increaseTitle?: boolean; + onCreated?: (childId: SessionId) => void; + }): Promise; + /** + * Borrow an already-retained Agent-scoped Context without extending its lifetime. + * @param id - session id. + * @returns the live scoped Context, or undefined without a retained generation. + */ + scope(id: SessionId): AgentContext | undefined; + /** + * Read the Agent scope tag off a context (service-method boundary: fetch + * bundles must reach scope resolution through ctx.sessions). + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined; + /** + * Resolve the session face behind an Agent-scoped context. + * @param ctx - an Agent-scoped context. + * @returns the matching live Session, or undefined for an untagged, foreign, or ended generation. + */ + sessionOf(ctx: Context): SessionFace | undefined; + /** + * Borrow an already-retained Session binding without extending its lifetime. + * @param id - session id. + * @returns the live binding, or undefined without a retained generation. + */ + binding(id: SessionId): SessionBinding | undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts new file mode 100644 index 000000000..de5514721 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts @@ -0,0 +1,90 @@ +/** Session-owned observable state excluding Conversation target data. */ +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { RemoteFailure } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRequestId } from "../../types.ts"; + +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string; + /** Browser file name, when the file had one. */ + readonly name?: string; + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number; + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number; +} + +/** Image branch of a local submission echo attachment. */ +export interface PendingSubmissionImageAttachment { + readonly type: "image"; + readonly value: PendingSubmissionImage; +} + +/** File branch of a local submission echo attachment. */ +export interface PendingSubmissionFileAttachment { + readonly type: "file"; + readonly value: FileAttachmentRef; +} + +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + PendingSubmissionImageAttachment | PendingSubmissionFileAttachment; + +/** Client surface selected when a local submission begins. */ +export type PendingSubmissionPlacement = "transcript" | "queued" | "steering"; + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId; + /** Surface fixed at submission time; Inbox acceptance does not move a Chat echo into the dock. */ + readonly placement: PendingSubmissionPlacement; + /** Client wall-clock ms when the submission began. */ + readonly time: number; + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string; + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[]; +} + +/** History-open lifecycle of a Session event window. */ +export type OpenState = "cold" | "loading" | "open" | "error"; + +/** Send/stop failure surfaced by Session consumers. */ +export interface PromptError { + readonly op: "send" | "stop"; + readonly error: RemoteFailure; +} + +/** Immutable Session lifecycle and control snapshot. */ +export interface SessionSnapshot { + readonly sessionId: SessionId; + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[]; + readonly running: boolean; + readonly subagent: { + readonly address: SubagentAddress; + /** Absent until the direct-parent catalog resolves. */ + readonly parentAvailable?: boolean; + } | null; + readonly removed: boolean; + readonly openState: OpenState; + readonly openError: RemoteFailure | null; + readonly hasMore: boolean; + readonly loadingOlder: boolean; + readonly promptError: PromptError | null; + readonly blank: boolean; + readonly lastAgentError: string | null; + /** A prompt call has begun on this Client Session object. */ + readonly promptAttempted: boolean; + /** The first accepted prompt has not reached a durable `turn/start` event. */ + readonly awaitingFirstTurn: boolean; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/index.ts b/apps/web-ui/packages/api/session-controller/src/client/index.ts new file mode 100644 index 000000000..e22ecb825 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/index.ts @@ -0,0 +1,171 @@ +/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent/types"; +import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; +import type {} from "@deepseek-ai/dsh-client-file-upload/client"; +import { typertOwnedValue } from "@deepseek-ai/dsh-typert-protocol"; +import { createSessionControlStream } from "./transport.ts"; +import { ClientSessions } from "./sessions/service.ts"; +import type { SessionRemotes } from "./sessions/remotes.ts"; +import type {} from "../remote-events.ts"; + +export { + createSessionControlStream, + SessionEventStream, + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from "./transport.ts"; +export type { + ClientSessionPageRequest, + SessionControlStream, + SessionControlStreamOptions, + SessionEventStreamOptions, + SessionJournalChange, + SessionRemote, +} from "./transport.ts"; +export { createScope, scopeOf } from "./scope.ts"; +export type { AgentContext, AgentScopeHandle } from "./scope.ts"; +export { SessionCreateError, SessionForkError } from "./sessions/service.ts"; +export type { SessionBinding, SessionListState, SessionSummary } from "./sessions/service.ts"; +export type { + SessionListPhase, + SessionListSnapshot, + SessionSearchResultItem, + SessionProjectionSnapshot, +} from "./sessions/manager.ts"; +export type { Session } from "./sessions/session.ts"; +export type { + ProjectionsBaseline, + ProjectionValueStore, + SessionProjectionMap, + UseProjection, +} from "./sessions/projection-store.ts"; +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from "./contract/session.ts"; +export type { + ISessions, + SessionReference, + SessionRetainInfo, + SessionRetainOptions, + SessionTarget, +} from "./contract/sessions.ts"; +export { MutableSessionEventSource } from "./contract/events.ts"; +export type { + AssistantLiveChunkEvent, + SessionAssistantSettlementEntry, + SessionEventChange, + SessionEventLike, + SessionEventLikeEntry, + SessionEventSource, + SessionEventWindow, + SessionLiveEventEntry, + SessionTransientEventEntry, +} from "./contract/events.ts"; +export type { + OpenState, + PendingSubmission, + PendingSubmissionAttachment, + PendingSubmissionFileAttachment, + PendingSubmissionImage, + PendingSubmissionImageAttachment, + PendingSubmissionPlacement, + PromptError, + SessionSnapshot, +} from "./contract/snapshot.ts"; + +/** Consumer-owned reference labels; extend this map through the package's canonical /client entry. */ +export interface SessionReferenceSourceMap { + /** Temporary Client Controller work, including fork-title preparation. */ + controllerOperation: unknown; + /** A Client Gateway invocation's synchronous Context ownership. */ + gateway: unknown; +} + +/** Declaration-merge-extensible labels carried by independent Client references. */ +export type SessionReferenceSource = Extract; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Client Session object layer and Agent scope owner. */ + sessions: import("./contract/sessions.ts").ISessions; + } +} + +/** Required Remote and Context projection services. */ +export const inject = [ + "connection", + "fileUpload", + "typert", + "remote", + "remote.commands", + "remote.session", + "remote.subagents", +]; + +/** + * Install Client Session state and its reconnecting control stream. + * @param ctx - Client Cordis context. + */ +export function apply(ctx: Context): void { + const remotes = ctx.remote as unknown as SessionRemotes; + const connection = ctx.get("connection") as ConnectionHandle; + const sessions = new ClientSessions(ctx, remotes); + ctx.remote.$on("api-session/added", (summary) => { + sessions.handleSessionAdded(summary); + }); + ctx.remote.$on("api-session/removed", (sessionId) => { + sessions.handleSessionRemoved(sessionId); + }); + ctx.remote.$on("api-session/status", (sessionId, running) => { + sessions.handleSessionStatus(sessionId, running); + }); + ctx.remote.$on("api-session/activity", (sessionId, updatedAt) => { + sessions.handleSessionActivity(sessionId, updatedAt); + }); + ctx.remote.$on("api-session/error", (sessionId, message) => { + sessions.handleSessionError(sessionId, message); + }); + + const control = createSessionControlStream(remotes, { + accept: (frame) => { + sessions.handleControlFrame(frame); + }, + failed: (error) => { + console.error("[session-controller] control stream failed:", error); + }, + }); + const connected = (): void => { + if (connection.generation.getSnapshot() === undefined) return; + // A ready control baseline may arrive before Cordis delivers connection/reset. + sessions.handleConnected(); + control.restart(); + control.start(); + }; + ctx.effect( + () => connection.generation.subscribe(connected), + "session-controller.client.generation", + ); + connected(); + ctx.typert.contexts.registerClient("agent", { + identity: (candidate) => sessions.sessionOf(candidate)?.sessionId, + resolve: (sessionId) => { + const reference = sessions.retainAgentScope(sessionId); + return typertOwnedValue(reference.binding.ctx, () => { + reference.release(); + }); + }, + }); + ctx.effect( + () => async () => { + await control.dispose(); + }, + "session-controller.client.control", + ); +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts b/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts new file mode 100644 index 000000000..73ae03ae4 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/ordered-baseline.ts @@ -0,0 +1,43 @@ +/** + * Merge an authoritative baseline without moving identities already visible to + * the client. Baseline-only identities are inserted relative to the nearest + * following known identity; identities absent from the baseline are removed. + * + * @param current - the established client order. + * @param baseline - the latest authoritative rows. + * @param keyOf - stable identity selector. + * @returns baseline-valued rows with the established relative order retained. + */ +export function mergeOrderedBaseline( + current: readonly T[], + baseline: readonly T[], + keyOf: (value: T) => unknown, +): T[] { + const baselineByKey = new Map(); + for (const value of baseline) baselineByKey.set(keyOf(value), value); + + const merged = current + .map((value) => baselineByKey.get(keyOf(value))) + .filter((value): value is T => value !== undefined); + const mergedKeys = new Set(merged.map(keyOf)); + + for (let index = 0; index < baseline.length; index++) { + const value = baseline[index]; + /* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */ + if (value === undefined || mergedKeys.has(keyOf(value))) continue; + let insertion = merged.length; + for (let following = index + 1; following < baseline.length; following++) { + const candidate = baseline[following]; + /* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */ + if (candidate === undefined) continue; + const known = merged.findIndex((item) => keyOf(item) === keyOf(candidate)); + if (known !== -1) { + insertion = known; + break; + } + } + merged.splice(insertion, 0, value); + mergedKeys.add(keyOf(value)); + } + return merged; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/scope.ts b/apps/web-ui/packages/api/session-controller/src/client/scope.ts new file mode 100644 index 000000000..0a1c161f6 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/scope.ts @@ -0,0 +1,76 @@ +/** Client scope generations route local events independently of Host Agent residency. */ +import { Context as CordisContext } from "@deepseek-ai/cordis"; +import type { Context, Fiber } from "@deepseek-ai/cordis"; +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TypertRemoteScopeApi } from "@deepseek-ai/dsh-typert-protocol"; + +/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ +export type AgentContext = Omit & { + readonly remote: ClientRemote & TypertRemoteScopeApi<"agent">; +}; + +/** Context tag written by {@link createScope}. */ +const kScope = Symbol("dsh.client.scope"); + +interface ScopeIdentity { + readonly sessionId: SessionId; +} + +/** A minted Agent scope and its disposal boundary. */ +export interface AgentScopeHandle { + /** + * Tagged context: scope-owned registrations and scoped dispatch both go + * through it (passing it as the dispatch subject routes to this generation's + * tagged listeners plus every untagged one). + */ + ctx: AgentContext; + /** Backing fiber (dispose tears down every scope-owned registration). */ + fiber: Fiber; +} + +/** Shared no-op plugin backing each Agent scope fiber. */ +function agentScope(): void {} + +/** + * Mint an Agent scope under `ctx`: a no-op plugin fiber whose context + * carries the agent tag and the dispatch filter — untagged listeners are + * admitted globally, tagged listeners only for the same Client generation. + * Registrations through the returned ctx dispose with the fiber. + * @param ctx - client root context the scope fiber mounts under. + * @param key - durable Session identity carried by this generation. + * @returns the tagged context and its backing fiber. + */ +export function createScope(ctx: Context, key: SessionId): AgentScopeHandle { + const fiber = ctx.plugin(agentScope); + const identity: ScopeIdentity = { sessionId: key }; + const scoped = fiber.ctx.extend({ + [kScope]: identity, + [CordisContext.filter](listenerCtx: Context): boolean { + const tag = scopeIdentityOf(listenerCtx); + return tag === undefined || tag === identity; + }, + }) as AgentContext; + return { + fiber, + ctx: scoped, + }; +} + +/** + * Read the nearest agent tag inherited by a context. + * @param ctx - any client context. + * @returns its agent identity (the session id), or undefined for root contexts. + */ +export function scopeOf(ctx: Context): SessionId | undefined { + return scopeIdentityOf(ctx)?.sessionId; +} + +/** + * Read the exact generation identity inherited by a Client Context. + * @param ctx - scoped or root Client Context. + * @returns the generation identity, or undefined for an unscoped Context. + */ +export function scopeIdentityOf(ctx: Context): ScopeIdentity | undefined { + return (ctx as Context & { [kScope]?: ScopeIdentity })[kScope]; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts b/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts new file mode 100644 index 000000000..1159a05f9 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/session-wire-event.ts @@ -0,0 +1,56 @@ +/** Event-local acceptance for raw Session journal responses; payloads remain owner-defined JSON. */ + +import { + validateSessionEventData, + validateSurfaceMetadata, +} from "@deepseek-ai/dsh-session/surface"; +import type { SessionEvent } from "@deepseek-ai/dsh-session/types"; +import type { SessionWireEvent } from "../types.ts"; + +/** + * Reject non-current event envelopes without stripping or normalizing wire fields. + * Range membership and source existence require the durable log and remain Host-owned. + * @param value - one event received in a follow frame or history page. + * @returns nothing after narrowing the accepted event envelope. + * @throws when the envelope or current event-local metadata is invalid. + */ +export function assertSessionWireEvent(value: unknown): asserts value is SessionWireEvent { + const subject = "session wire event"; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${subject} must be an object`); + } + const event = value as Record; + for (const key of Object.keys(event)) { + switch (key) { + case "type": + case "seq": + case "time": + case "data": + case "ignorable": + case "surfaceOp": + case "sourceEventSeqs": + break; + default: + throw new Error(`${subject} has unexpected field ${key}`); + } + } + const seq = event["seq"]; + if ( + typeof event["type"] !== "string" || + typeof seq !== "number" || + !Number.isSafeInteger(seq) || + seq < 0 || + Object.is(seq, -0) || + typeof event["time"] !== "number" || + !Number.isSafeInteger(event["time"]) || + !Object.hasOwn(event, "data") || + event["data"] === undefined || + (Object.hasOwn(event, "ignorable") && event["ignorable"] !== true) + ) { + throw new Error(`${subject} has an invalid envelope`); + } + // Event names and payloads are merge-extensible; only event-local owner rules run here. + const current = event as SessionEvent; + validateSurfaceMetadata(current); + validateSessionEventData(current, subject); +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts new file mode 100644 index 000000000..f6f32753f --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/assistant-stream.ts @@ -0,0 +1,240 @@ +/** Web presentation fold joining transient Assistant frames to one durable v2 settlement. */ + +import type { SessionAssistantStreamBaseline, SessionAssistantStreamFrame } from "../../types.ts"; +import { expandAssistantStream } from "@deepseek-ai/dsh-llm/assistant-stream"; +import type { AssistantStreamRecord } from "@deepseek-ai/dsh-llm/assistant-stream"; +import type { LlmAttemptId } from "@deepseek-ai/dsh-llm/brand"; +import type { + SessionAssistantSettlementEntry, + SessionEventLikeEntry, + SessionLiveEventEntry, + SessionTransientEventEntry, +} from "../contract/events.ts"; + +interface ActiveAttempt { + readonly attemptId: LlmAttemptId; + readonly startedAfterSeq: number; + readonly turn: number; + readonly step: number; + nextIndex: number; +} + +/** One Web publication decision from the assistant stream fold. */ +export type ClientAssistantStreamResult = + | { + readonly type: "publish"; + readonly entry: SessionLiveEventEntry; + /** Retire the successful attempt's transient rows after publishing its Step end. */ + readonly retireAttemptId?: LlmAttemptId; + } + | { + readonly type: "settlement"; + readonly attemptId: LlmAttemptId; + readonly entry: SessionAssistantSettlementEntry; + } + | { readonly type: "abandonment"; readonly attemptId: LlmAttemptId } + | { readonly type: "transient"; readonly entry: SessionTransientEventEntry } + | { readonly type: "rebaseline" } + | undefined; + +/** Keeps transient Assistant presentation behind one settlement-aware interface. */ +export class ClientAssistantStream { + private activeAttempt: ActiveAttempt | undefined; + private retainedAttempt: Pick | undefined; + private readonly pending = new Map(); + private publishedSeqs = new Set(); + private durableCursor = -1; + private transientInGap = 0; + + /** + * Replace the durable Web window and adopt an optional reconnect baseline. + * @param entries - durable entries in the replacement window. + * @param baseline - compact prefix for an Assistant attempt that is still live. + * @returns immediately visible durable entries plus reconstructed transient chunks. + */ + replace( + entries: readonly SessionEventLikeEntry[], + baseline?: SessionAssistantStreamBaseline, + ): readonly SessionEventLikeEntry[] { + this.pending.clear(); + this.transientInGap = 0; + this.activeAttempt = undefined; + this.retainedAttempt = undefined; + const opening = baseline?.activeAttempt; + if (opening !== undefined) { + this.activeAttempt = { + attemptId: opening.attemptId, + startedAfterSeq: opening.startedAfterSeq, + turn: opening.turn, + step: opening.step, + nextIndex: opening.nextIndex, + }; + } + const visible: SessionEventLikeEntry[] = [...entries]; + this.publishedSeqs = new Set(visible.map((entry) => entry.event.seq)); + this.durableCursor = visible.reduce((cursor, entry) => Math.max(cursor, entry.event.seq), -1); + if (opening !== undefined) { + for (const [index, member] of expandAssistantStream( + opening.stream as unknown as readonly AssistantStreamRecord[], + ).entries()) { + this.transientInGap += 1; + visible.push({ + type: "transient", + event: { + type: "assistant/live-chunk", + seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1), + time: member.time, + data: { + attemptId: opening.attemptId, + turn: opening.turn, + step: opening.step, + chunk: member.chunk, + }, + }, + }); + if (index + 1 >= opening.nextIndex) break; + } + } + return visible; + } + + /** + * Stage one durable v2 settlement while its matching live attempt is open. + * @param entry - newly followed durable entry. + * @returns a publication decision, or `undefined` when no entry becomes visible. + */ + acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult { + const event = entry.event; + this.durableCursor = Math.max(this.durableCursor, event.seq); + this.transientInGap = 0; + const settlement = assistantSettlementEntry(entry); + if (settlement !== undefined && this.attemptForSettlement(settlement.event) !== undefined) { + if (this.pending.has(event.seq)) return { type: "rebaseline" }; + this.pending.set(event.seq, settlement); + return undefined; + } + return this.publish(entry); + } + + /** + * Fold one dense transient frame and release its named durable settlement. + * Successful messages retain their transient rows until the owning Step ends; + * interrupted messages, failed attempts, and abandonment retire them immediately. + * @param frame - next Assistant stream frame received by the follow connection. + * @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible. + */ + acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult { + switch (frame.type) { + case "start": + if ( + this.activeAttempt !== undefined || + this.retainedAttempt !== undefined || + this.pending.size > 0 + ) + return { type: "rebaseline" }; + this.pending.clear(); + this.activeAttempt = { + attemptId: frame.attemptId, + startedAfterSeq: frame.startedAfterSeq, + turn: frame.turn, + step: frame.step, + nextIndex: 0, + }; + return undefined; + case "chunk": { + const attempt = this.activeAttempt; + // A controller mounted after the Host saw this attempt has no start + // frame to reconstruct. Its durable settlement publishes directly; + // ignore the transient suffix until the next known start. + if (attempt === undefined || attempt.attemptId !== frame.attemptId) return undefined; + if (frame.index !== attempt.nextIndex) return { type: "rebaseline" }; + attempt.nextIndex += 1; + this.transientInGap += 1; + return { + type: "transient", + entry: { + type: "transient", + event: { + type: "assistant/live-chunk", + seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1), + time: frame.time, + data: { + attemptId: frame.attemptId, + turn: attempt.turn, + step: attempt.step, + chunk: frame.chunk as never, + }, + }, + }, + }; + } + case "end": { + const attempt = this.activeAttempt; + if (attempt === undefined || attempt.attemptId !== frame.attemptId) { + return undefined; + } + this.activeAttempt = undefined; + if (frame.index !== attempt.nextIndex) return { type: "rebaseline" }; + if (frame.outcome.kind === "abandoned") { + return this.pending.size === 0 + ? { type: "abandonment", attemptId: attempt.attemptId } + : { type: "rebaseline" }; + } + if (this.publishedSeqs.has(frame.outcome.seq)) return undefined; + const entry = this.pending.get(frame.outcome.seq); + if (entry === undefined || entry.event.type !== frame.outcome.eventType) { + return { type: "rebaseline" }; + } + this.pending.delete(frame.outcome.seq); + if (entry.event.type === "assistant/message" && entry.event.data.interrupted !== true) { + this.retainedAttempt = { + attemptId: attempt.attemptId, + turn: attempt.turn, + step: attempt.step, + }; + return this.publish(entry); + } + this.publishedSeqs.add(entry.event.seq); + return { type: "settlement", attemptId: attempt.attemptId, entry }; + } + } + } + + private attemptForSettlement( + event: SessionAssistantSettlementEntry["event"], + ): ActiveAttempt | undefined { + const attempt = this.activeAttempt; + if ( + attempt === undefined || + (event.type === "assistant/message" && event.surfaceOp !== "append") || + event.seq <= attempt.startedAfterSeq || + attempt.turn !== event.data.turn || + attempt.step !== event.data.step + ) + return undefined; + return attempt; + } + + private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult { + this.publishedSeqs.add(entry.event.seq); + const retained = this.retainedAttempt; + if ( + retained !== undefined && + entry.event.type === "step/end" && + entry.event.data.turn === retained.turn && + entry.event.data.step === retained.step + ) { + this.retainedAttempt = undefined; + return { type: "publish", entry, retireAttemptId: retained.attemptId }; + } + return { type: "publish", entry }; + } +} + +function assistantSettlementEntry( + entry: SessionLiveEventEntry, +): SessionAssistantSettlementEntry | undefined { + return entry.event.type === "assistant/message" || entry.event.type === "assistant/attempt" + ? (entry as SessionAssistantSettlementEntry) + : undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts new file mode 100644 index 000000000..3bac83c4d --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/history-records.ts @@ -0,0 +1,33 @@ +/** Client range access and type narrowing for aligned Session history records. */ + +import type { SessionHistoryRecord } from "../../types.ts"; +import type { SessionEventLikeEntry } from "../contract/events.ts"; + +/** + * Narrow aligned wire records to their Client event types without allocation. + * @param records - validated history transport records. + * @returns the same record array with typed inner events. + */ +export function historyEntries( + records: readonly SessionHistoryRecord[], +): readonly SessionEventLikeEntry[] { + return records as unknown as readonly SessionEventLikeEntry[]; +} + +/** + * Read the first logical sequence represented by one wire record. + * @param record - validated Session event. + * @returns inclusive first Session sequence. + */ +export function historyRecordFirstSeq(record: SessionHistoryRecord): number { + return record.event.seq; +} + +/** + * Read the final logical sequence represented by one wire record. + * @param record - validated Session event. + * @returns inclusive final Session sequence. + */ +export function historyRecordLastSeq(record: SessionHistoryRecord): number { + return record.event.seq; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts new file mode 100644 index 000000000..b77333a27 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/lineage.ts @@ -0,0 +1,83 @@ +// flattenLineage: summaries -> flat list with lineage indentation (pure function). +// The input order is authoritative; lineage only makes each child adjacent to its parent. +// Orphaned lineage degrades to root level; cycles fail soft and emit as roots. + +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { SessionSummary } from "../../types.ts"; + +/** Host list summary enriched with the latest Session Controller title projection. */ +export interface TitledSessionSummary extends SessionSummary { + title?: string; + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly>; +} + +/** One flattened session-list row with lineage depth. */ +export interface SessionListEntry { + sessionId: SessionId; + title?: string; + updatedAt: number; + running: boolean; + /** + * New Session presentation and reuse eligibility, reconciled with + * `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). + */ + blank: boolean; + parentSessionId?: SessionId; + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: "subagent"; + cwd?: string; + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly>; + /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ + depth: number; +} + +/** + * Summaries -> flat list with lineage indentation. Root and sibling order + * follows the established input order; this projection never re-sorts a + * hydrated list from mutable timestamps. + * @param summaries - the host's session.list items. + * @returns display rows in render order. + */ +export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { + const byId = new Map(); + for (const s of summaries) byId.set(s.sessionId, s); + + const children = new Map(); + const roots: TitledSessionSummary[] = []; + for (const s of summaries) { + if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { + const list = children.get(s.parentSessionId) ?? []; + list.push(s); + children.set(s.parentSessionId, list); + } else { + roots.push(s); // root, or an orphan whose parent is absent from summaries (degrade to root, never drop) + } + } + + const out: SessionListEntry[] = []; + const visited = new Set(); + const walk = (s: TitledSessionSummary, depth: number): void => { + if (visited.has(s.sessionId)) { + console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`); + return; + } + visited.add(s.sessionId); + const { agentAvailable: _agentAvailable, ...row } = s; + out.push({ + ...row, + depth, + }); + const kids = children.get(s.sessionId); + if (kids === undefined) return; + for (const kid of kids) walk(kid, depth + 1); + }; + for (const root of roots) walk(root, 0); + // Cycle members (unreachable from any root): emit as roots so no entry is lost. + for (const s of summaries) { + if (!visited.has(s.sessionId)) walk(s, 0); + } + return out; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts new file mode 100644 index 000000000..aab4d2329 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/manager.ts @@ -0,0 +1,944 @@ +/** Host catalog, durable projection caches, and explicitly retained Client instances. */ + +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import { SessionSeq, type SessionId, type SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { + SessionControlBaseline, + SessionControlFrame, + SessionProjectionHints, + SessionRenameValue, + SessionSummary, +} from "../../types.ts"; +import { mergeOrderedBaseline } from "../ordered-baseline.ts"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionListEntry, TitledSessionSummary } from "./lineage.ts"; +import { flattenLineage } from "./lineage.ts"; +// Type-only merge edge: the title domain's client-namespace outlet declares +// the 'title' projection key this manager projects into list rows (and any +// useProjection('title') consumer reads). Zero value imports by construction. +import type {} from "@deepseek-ai/dsh-session-title/client"; +import { Notifier } from "./notifier.ts"; +import { ProjectionValueStore } from "./projection-store.ts"; +import { Session } from "./session.ts"; +import type { SessionRemotes } from "./remotes.ts"; +import type { SessionTarget } from "../contract/sessions.ts"; + +function sessionSeqCursor(value: number): SessionSeqCursor { + return value === -1 ? -1 : SessionSeq(value); +} + +/** + * List arrival lifecycle, orthogonal to the pull-activity `state` axis: + * `pending` (no successful pull yet — an empty items array means "nothing + * arrived", not "nothing exists") → `ready` (at least one pull landed). + * Monotone: `ready` never steps back — later pull failures and reconnect + * re-pulls ride the `state`/`error` axis, which is where failure is modeled + * (no `error` phase here; that would duplicate `state`). + */ +export type SessionListPhase = "pending" | "ready"; + +/** Request-local content hit returned to sidebar search consumers. */ +export interface SessionSearchResultItem { + sessionId: SessionId; + snippet: string; +} + +/** Immutable session-list snapshot for useSessionList. */ +export interface SessionListSnapshot { + items: readonly SessionListEntry[]; + state: "idle" | "loading" | "error"; + /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ + phase: SessionListPhase; + error: RemoteFailure | null; + projectionsBySession: Readonly>; +} + +/** Shared projection values and the lifecycle of their explicit baseline read. */ +export interface SessionProjectionSnapshot { + readonly values: Readonly>; + readonly state: "idle" | "loading" | "ready" | "error"; + readonly error: RemoteFailure | null; +} + +interface ProjectionInflight { + readonly promise: Promise; + readonly controller: AbortController; +} + +type ProjectionLoad = Omit; + +type SessionListMutation = + | { kind: "upsert" | "placeholder"; summary: SessionSummary } + | { kind: "remove"; sessionId: SessionId } + | { kind: "status"; sessionId: SessionId; running: boolean; agentAvailable: boolean } + | { kind: "activity"; sessionId: SessionId; updatedAt: number } + /** Local first-send flip: the sender clears blank without waiting for a host frame. */ + | { kind: "engaged"; sessionId: SessionId }; + +/** Instance cluster + frame entry + the session list. */ +export class SessionManager { + private readonly sessions = new Map(); + /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ + private readonly sessionDisposals = new Set>(); + /** + * Accepted/running presentation must survive a later empty-history list + * response. Host-asserted running is recorded even before a row, instance, or + * address holds the identity — the listing that would hold it may not have + * landed yet — while the client-local acceptance callback requires a current + * holder, because it can arrive from a replaced or already-dropped Session. + */ + private readonly engagedSessions = new Set(); + private disposed = false; + /** Per-session projection value stores, retained independently of instance arrival (the + * title-snapshot precedent, generalized): push frames land here whether or not the Session + * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the + * same store so history-baseline seeding and frames converge on one row set. */ + private readonly projectionStores = new Map(); + private summaries: SessionSummary[] = []; + private listState: "idle" | "loading" | "error" = "idle"; + /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ + private listPhase: SessionListPhase = "pending"; + private listError: RemoteFailure | null = null; + private listInflight: Promise | null = null; + /** Active list request's mutation log; its identity also fences completion after reconnect. */ + private listMutations: SessionListMutation[] | null = null; + private readonly addresses = new Map(); + private readonly projectionLoads = new Map(); + private readonly projectionInflight = new Map(); + + private listSnapshotCache: SessionListSnapshot; + /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry + * object when every field matches — wire refreshes mint all-new summary objects, so identity + * must be recovered by value or every SessionListItem memo misses on every refresh. */ + private entryCache = new Map(); + private itemsCache: readonly SessionListEntry[] = []; + private readonly notifier = new Notifier(() => { + this.listSnapshotCache = this.buildListSnapshot(); + }); + + /** @param remote - generated Remote namespaces used by catalog and history readers. */ + constructor(private readonly remote: SessionRemotes) { + this.listSnapshotCache = this.buildListSnapshot(); + } + + /** + * Resolve an acquisition target without materializing a Session. + * @param target - known identity or durable direct-parent address. + * @returns the resolved identity with its explicit or catalog-derived history route installed. + */ + resolveTarget(target: SessionTarget): SessionId { + const id = typeof target === "string" ? target : target.childSessionId; + const address = typeof target === "string" ? this.subagentAddress(id) : target; + if ( + typeof target === "string" && + !this.sessions.has(id) && + !this.summaries.some((summary) => summary.sessionId === id) && + address === undefined + ) { + throw new Error(`sessions.retain: unknown session ${id}`); + } + if (address !== undefined) this.addresses.set(id, address); + this.sessions + .get(id) + ?.configureSubagent( + address, + address === undefined ? undefined : this.agentAvailable(address.parentSessionId), + ); + return id; + } + + /** + * Resolve an address for breadcrumb navigation without retaining transport authority. + * @param sessionId - possible child id in an already-loaded catalog. + * @returns A retained or catalog-derived direct-parent address. + */ + subagentAddress(sessionId: SessionId): SubagentAddress | undefined { + const retained = this.addresses.get(sessionId); + if (retained !== undefined) return retained; + for (const parentSessionId of this.projectionStores.keys()) { + const child = this.projectionStores + .get(parentSessionId) + ?.values() + .subagentCatalog?.find((entry) => entry.id === sessionId); + if (child !== undefined) { + return { + parentSessionId, + childSessionId: sessionId, + mode: child.mode, + }; + } + } + return undefined; + } + + // ---- Instance management ---- + + /** + * Withdraw an exact Client instance before running its teardown callbacks. + * @param sessionId - identity to withdraw. + * @param expected - instance being released; a replacement is left untouched. + * @returns completion of the detached instance's stream teardown. + */ + drop(sessionId: SessionId, expected: Session): Promise { + const session = this.sessions.get(sessionId); + if (session !== expected) return Promise.resolve(); + this.sessions.delete(sessionId); + this.addresses.delete(sessionId); + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)); + return this.startSessionDisposal(session); + } + + /** + * Stop catalog requests and dispose every resident Session. + * @returns once catalog requests and every Session stream have stopped. + */ + async dispose(): Promise { + this.disposed = true; + this.listMutations = null; + this.listInflight = null; + this.engagedSessions.clear(); + const reads = [...this.projectionInflight.values()]; + for (const { controller } of reads) controller.abort(); + this.projectionInflight.clear(); + await Promise.all(reads.map((read) => read.promise)); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + this.addresses.clear(); + for (const session of sessions) void this.startSessionDisposal(session); + await this.drainSessionDisposals(); + } + + private startSessionDisposal(session: Session): Promise { + const disposal = session.dispose(); + this.sessionDisposals.add(disposal); + void disposal.then( + () => { + this.sessionDisposals.delete(disposal); + }, + () => { + this.sessionDisposals.delete(disposal); + }, + ); + return disposal; + } + + private async drainSessionDisposals(): Promise { + while (this.sessionDisposals.size > 0) { + await Promise.allSettled([...this.sessionDisposals]); + } + } + + /** + * Lazy build: return the existing instance or construct one (no auto-open — + * the reference allocator opens history after binding the scope). + * New instances reconcile retained metadata before returning. + * @param sessionId - the session to get. + * @returns the resident instance. + */ + get(sessionId: SessionId): Session { + let session = this.sessions.get(sessionId); + if (session === undefined) { + session = this.createSession(sessionId); + this.sessions.set(sessionId, session); + // Sync the running and blank bits from the list snapshot into the new + // instance (consistency when the list precedes open). + const summary = this.summaries.find((s) => s.sessionId === sessionId); + if (summary !== undefined) { + session.handleBlank(this.effectiveBlank(summary)); + session.handleRunning(summary.running); + } else { + const address = this.addresses.get(sessionId); + const child = + address === undefined + ? undefined + : this.projectionStores + .get(address.parentSessionId) + ?.values() + .subagentCatalog?.find((entry) => entry.id === sessionId); + if (child !== undefined) { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false); + session.handleRunning(false); + } else { + // Retained metadata may have notified before this Session existed. + session.handleBlank(true); + } + } + } + return session; + } + + private createSession(sessionId: SessionId): Session { + const address = this.addresses.get(sessionId); + const parentAvailable = + address === undefined ? undefined : this.agentAvailable(address.parentSessionId); + return new Session(sessionId, this.remote, { + ...(address === undefined + ? {} + : { + address, + ...(parentAvailable === undefined ? {} : { parentAvailable }), + }), + // The sender's local first-send flip mirrors into the list row so the + // session surfaces (lists filter on blank) before any host frame lands. + onEngaged: (engaged) => { + if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return; + // An identity already on the ledger needs no second row mutation. + if (!this.engagedSessions.has(engaged.sessionId)) { + this.engagedSessions.add(engaged.sessionId); + this.recordMutation({ kind: "engaged", sessionId: engaged.sessionId }); + } + this.sessions.get(engaged.sessionId)?.handleBlank(false); + }, + projections: this.projectionStore(sessionId), + }); + } + + private effectiveBlank(summary: SessionSummary): boolean { + return summary.blank && !this.engagedSessions.has(summary.sessionId); + } + + /** + * Identities an engagement may still belong to: the given list rows, resident + * Session instances, and retained child addresses. + * @param summaries - list rows of the caller's snapshot. + * @returns the retained identity set. + */ + private retainedIds(summaries: readonly SessionSummary[]): Set { + const retained = new Set(summaries.map((summary) => summary.sessionId)); + for (const sessionId of this.sessions.keys()) retained.add(sessionId); + for (const sessionId of this.addresses.keys()) retained.add(sessionId); + return retained; + } + + /** + * Forget one engagement that no retained identity holds. + * @param sessionId - identity whose engagement may be dropped. + * @param retained - identities from {@link retainedIds} for the caller's snapshot. + */ + private pruneEngagement(sessionId: SessionId, retained: ReadonlySet): void { + if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId); + } + + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ + private projectionStore(sessionId: SessionId): ProjectionValueStore { + let store = this.projectionStores.get(sessionId); + if (store === undefined) { + store = new ProjectionValueStore(); + const projections = store; + store.subscribeAny(() => { + // Newer history or control metadata corrects a resident Session's stale list hint. + if (projections.values().sessionListMetadata?.blank === false) { + this.sessions.get(sessionId)?.handleBlank(false); + } + this.notifier.markDirty(); + }); + this.projectionStores.set(sessionId, store); + } + return store; + } + + /** + * Load a complete projection baseline once per connection; retry unsuccessful reads. + * @param sessionId - Session to inspect without opening its conversation. + * @returns completion of the current or newly started read. + */ + refreshProjections(sessionId: SessionId): Promise { + const existing = this.projectionInflight.get(sessionId); + if (existing !== undefined) return existing.promise; + if (this.projectionLoads.get(sessionId)?.state === "ready") return Promise.resolve(); + const controller = new AbortController(); + const store = this.projectionStore(sessionId); + const initialValues = store.values(); + this.projectionLoads.set(sessionId, { state: "loading", error: null }); + this.notifier.markDirty(); + const operation = (async () => { + try { + const result = await this.remote.session.projections({ sessionId }, controller.signal); + if (controller.signal.aborted) return; + if (result.ok) { + if (result.value !== null) { + store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) }); + } else if (store.values() === initialValues) { + // A later frame proves existence independently of an earlier missing read. + store.clear(); + } + this.projectionLoads.set(sessionId, { state: "ready", error: null }); + } else { + this.projectionLoads.set(sessionId, { state: "error", error: result.error }); + } + } catch (error: unknown) { + if (controller.signal.aborted) return; + if (!isRemoteFailure(error)) throw error; + this.projectionLoads.set(sessionId, { state: "error", error }); + } finally { + if (!controller.signal.aborted) { + this.projectionInflight.delete(sessionId); + this.notifier.markDirty(); + } + } + })(); + this.projectionInflight.set(sessionId, { promise: operation, controller }); + return operation; + } + + private agentAvailable(sessionId: SessionId): boolean | undefined { + return ( + this.summaries.find((summary) => summary.sessionId === sessionId)?.agentAvailable ?? + (this.listPhase === "ready" ? false : undefined) + ); + } + + private updateParentAvailability(): void { + for (const [childId, address] of this.addresses) { + const available = this.agentAvailable(address.parentSessionId); + if (available !== undefined) + this.sessions.get(childId)?.handleSubagentParentAvailable(available); + } + } + + // ---- List API ---- + + /** Full refresh via session.list (single-flight within one Host generation). */ + refreshList(): Promise { + if (this.listInflight !== null) return this.listInflight; + this.listState = "loading"; + this.listError = null; + const established = this.summaries; + const mutations: SessionListMutation[] = []; + this.listMutations = mutations; + this.notifier.markDirty(); + this.listInflight = (async () => { + try { + const result = await this.remote.session.list({}); + if (this.listMutations !== mutations) return; + if (result.ok) { + const baseline: SessionSummary[] = + this.listPhase === "pending" + ? [...result.value.items] + : mergeOrderedBaseline( + established, + result.value.items, + (summary) => summary.sessionId, + ); + // A removal supersedes running observed in the pull, including after re-addition. + const removedSincePull = new Set(); + for (const mutation of mutations) { + if (mutation.kind === "remove") removedSincePull.add(mutation.sessionId); + } + for (const s of baseline) { + if (s.running && !removedSincePull.has(s.sessionId)) + this.engagedSessions.add(s.sessionId); + } + const summaries = mutations.reduce(applyMutation, baseline); + this.summaries = summaries; + // A full list can remove identities without a removal frame. + const retained = this.retainedIds(summaries); + for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained); + this.listState = "idle"; + this.listPhase = "ready"; + this.updateParentAvailability(); + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. + for (const s of this.summaries) { + const session = this.sessions.get(s.sessionId); + if (session === undefined) continue; + session.handleBlank(this.effectiveBlank(s)); + session.handleRunning(s.running); + } + // Land each row's projection block in the per-session value store + // (cold titles surface without opening the session). A list block is + // partial — only the keys its source holds — so an absent key never + // clears; how the present keys merge depends on the block's kind. + for (const s of result.value.items) { + if (s.projections !== undefined) this.applyListBlock(s.sessionId, s.projections); + } + } else { + this.listState = "error"; + this.listError = result.error; + } + } catch (error) { + if (!isRemoteFailure(error)) throw error; + if (this.listMutations !== mutations) return; + this.listState = "error"; + this.listError = error; + } finally { + if (this.listMutations === mutations) { + this.listMutations = null; + this.listInflight = null; + this.notifier.markDirty(); + } + } + })(); + return this.listInflight; + } + + /** + * Search visible session message content without adding transient query + * state to the list snapshot. + * @param query - non-blank literal phrase. + * @param signal - cancellation for superseded UI queries. + * @returns the Host result or a folded transport error. + */ + async search( + query: string, + signal: AbortSignal, + ): Promise> { + const result = await this.remote.session.search({ query }, signal); + if (!result.ok) return result; + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, + }; + } + + /** + * Contract session.create; on success merge into summaries immediately (no + * wait for the next refresh). A created session is blank by definition + * (entity birth precedes the first message). + * @param opts - target workspace or working directory, plus an optional caller-owned id. + * @returns the create result. + */ + async create( + opts: { + workspaceId?: WorkspaceId; + cwd?: string; + sessionId?: SessionId; + } = {}, + ): Promise> { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }; + const payload = + opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }; + const result = await this.remote.session.create(payload); + if (result.ok) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: result.value.sessionId, + updatedAt: Date.now(), + running: false, + blank: true, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + }, + }); + } else { + const publishedSessionId = workspaceAttachSessionId(result.error); + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + blank: true, + }, + }); + } + } + return result; + } + + /** + * Contract session.fork; on success merge the child into summaries + * immediately (same synchronous-addressability guarantee as create). + * Blankness starts provisionally true so the authoritative Host summary can + * preserve it or lower it after an exact cut before the first `turn/start`; + * lineage rides parentSessionId. A child published before Workspace + * attachment fails is also reconciled into the list. + * @param opts - source session and the optional exact inclusive boundary seq. + * @returns the fork result (the child session id). + */ + async fork(opts: { + sessionId: SessionId; + atSeq?: SessionSeq; + }): Promise> { + const source = this.summaries.find((s) => s.sessionId === opts.sessionId); + const result = await this.remote.session.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }), + }); + const childId = result.ok ? result.value.sessionId : workspaceAttachSessionId(result.error); + if (childId !== undefined) { + this.recordMutation({ + kind: "placeholder", + summary: { + agentAvailable: true, + sessionId: childId, + updatedAt: Date.now(), + running: false, + blank: true, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + }, + }); + } + return result; + } + + /** + * Rename a Session and update its title projection without opening its history. + * @param sessionId - Session to rename. + * @param title - raw title text for Host normalization. + * @returns the accepted title and event position, or the Remote failure. + */ + async rename(sessionId: SessionId, title: string): Promise> { + const result = await this.remote.session.rename({ sessionId, title }); + if (result.ok) { + this.projectionStore(sessionId).apply( + "title", + result.value.title, + SessionSeq(result.value.seq), + ); + } + return result; + } + + /** + * Merge a Host summary, replacing live state and filling missing metadata. + * Local create/fork placeholders only fill metadata on an existing row. + */ + private mergeSummary(summary: SessionSummary): void { + this.recordMutation({ kind: "upsert", summary }); + this.updateParentAvailability(); + } + + /** Apply immediately and retain for replay when a list response is in flight. */ + private recordMutation(mutation: SessionListMutation): void { + if (this.disposed) return; + this.listMutations?.push(mutation); + this.summaries = applyMutation(this.summaries, mutation); + this.notifier.markDirty(); + } + + // ---- Subscription API (for useSessionList) ---- + + /** + * uSES subscription entry for useSessionList. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener); + } + + /** + * Cached list snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getListSnapshot(): SessionListSnapshot { + this.notifier.ensureFresh(); + return this.listSnapshotCache; + } + + /** + * Read cached projection values for a Session that may exist only in a loaded subagent catalog. + * @param sessionId - Session whose control or history baseline supplied projections. + * @returns current values, or undefined before any projection store exists. + */ + projectionValues(sessionId: SessionId): Readonly> | undefined { + return this.projectionStores.get(sessionId)?.values(); + } + + // ---- Live control and Host-event sinks ---- + + /** + * Apply a complete control baseline or one later replacement frame. + * @param frame - baseline or live control replacement from Session Controller. + */ + handleControlFrame(frame: SessionControlFrame): void { + if (frame.type === "baseline") { + this.replaceControlBaseline(frame.value); + return; + } + this.projectionStore(frame.sessionId).applyLive(frame.key, frame.value, SessionSeq(frame.seq)); + this.notifier.markDirty(); + } + + private replaceControlBaseline(baseline: SessionControlBaseline): void { + for (const [sessionId, block] of Object.entries(baseline.projections)) { + const store = this.projectionStore(sessionId as SessionId); + const asOfSeq = sessionSeqCursor(block.asOfSeq); + store.seed({ ...block, asOfSeq }); + } + this.notifier.markDirty(); + } + + /** + * Apply one Session-list addition forwarded through `ctx.remote.$on`. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: SessionSummary): void { + this.mergeSummary(summary); + if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId); + this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary)); + if (summary.projections !== undefined) + this.applyListBlock(summary.sessionId, summary.projections); + } + + /** + * Merge one list-surface projection block by the sequence space it declares. + * A `sequenced` block came from the Host's live registry for an attached + * Session, so each key lands under higher-seq-wins against that Session's + * baselines and frames. A `cached` block was viewed from the persisted + * checkpoint by a header-only listing: its watermark is not comparable with + * this connection's seqs, so it only fills keys no sequenced row holds. + */ + private applyListBlock(sessionId: SessionId, block: SessionProjectionHints): void { + const store = this.projectionStore(sessionId); + switch (block.kind) { + case "sequenced": { + const seq = sessionSeqCursor(block.asOfSeq); + for (const [key, value] of Object.entries(block.values)) store.apply(key, value, seq); + return; + } + case "cached": + store.applyCached(block.values); + return; + default: + assertNever(block.kind, "session list projection block kind"); + } + } + + /** + * Apply one Session removal forwarded through `ctx.remote.$on`. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: SessionId): void { + const durableSubagent = + this.subagentAddress(sessionId) !== undefined || + this.summaries.some( + (summary) => summary.sessionId === sessionId && summary.origin === "subagent", + ); + this.recordMutation( + durableSubagent + ? { kind: "status", sessionId, running: false, agentAvailable: false } + : { kind: "remove", sessionId }, + ); + if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false); + else this.sessions.get(sessionId)?.handleRemoved(); + const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog; + if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { + this.projectionStores.delete(sessionId); + } + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)); + this.projectionInflight.get(sessionId)?.controller.abort(); + this.projectionInflight.delete(sessionId); + this.projectionLoads.delete(sessionId); + for (const [childId, address] of this.addresses) { + if (address.parentSessionId === sessionId) { + this.sessions.get(childId)?.handleSubagentParentAvailable(false); + } + } + } + + /** + * Apply one live Agent running-state change. + * @param sessionId - Session whose Agent state changed. + * @param running - current Agent running state. + */ + handleSessionStatus(sessionId: SessionId, running: boolean): void { + if (!this.disposed && running) this.engagedSessions.add(sessionId); + this.recordMutation({ kind: "status", sessionId, running, agentAvailable: true }); + this.updateParentAvailability(); + this.sessions.get(sessionId)?.handleRunning(running); + } + + /** + * Advance Session-list activity from one user-authored durable message. + * @param sessionId - Session whose activity changed. + * @param updatedAt - durable message timestamp. + */ + handleSessionActivity(sessionId: SessionId, updatedAt: number): void { + this.recordMutation({ kind: "activity", sessionId, updatedAt }); + } + + /** + * Surface one live Agent failure on an already-materialized Session. + * @param sessionId - Session whose Agent failed. + * @param message - caller-visible failure description. + */ + handleSessionError(sessionId: SessionId, message: string): void { + this.sessions.get(sessionId)?.handleAgentError(message); + } + + /** + * Repair one re-established Host-event generation with queryable baselines. + * Discard old projection cuts before new queries, including cold Sessions + * absent from the process-local control baseline. + * Opened Session follow streams resume independently through API Gateway. + */ + handleConnected(): void { + for (const store of this.projectionStores.values()) store.clear(); + this.listMutations = null; + this.listInflight = null; + void this.refreshList(); + const parents = new Set(this.projectionLoads.keys()); + for (const id of this.sessions.keys()) { + const address = this.addresses.get(id); + if (address !== undefined) parents.add(address.parentSessionId); + } + for (const { controller } of this.projectionInflight.values()) controller.abort(); + this.projectionInflight.clear(); + this.projectionLoads.clear(); + for (const parentSessionId of parents) void this.refreshProjections(parentSessionId); + } + + private buildListSnapshot(): SessionListSnapshot { + const merged: TitledSessionSummary[] = this.summaries.map((summary) => { + // List rows read the generic 'title' projection key (host-computed unit + // value; there is no dedicated title frame). + const projectionStore = this.projectionStores.get(summary.sessionId); + const title = projectionStore?.get("title"); + const projectionValues = projectionStore?.values(); + const metadata = projectionValues?.sessionListMetadata; + return { + ...summary, + // Cached list hints can precede a history opening or control update. + blank: this.effectiveBlank(summary) && metadata?.blank !== false, + updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), + ...(typeof title === "string" && title !== "" ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + }; + }); + const fresh = flattenLineage(merged); + const items = fresh.map((entry) => { + const prev = this.entryCache.get(entry.sessionId); + if ( + prev !== undefined && + prev.updatedAt === entry.updatedAt && + prev.running === entry.running && + prev.blank === entry.blank && + prev.parentSessionId === entry.parentSessionId && + prev.cwd === entry.cwd && + prev.origin === entry.origin && + prev.title === entry.title && + prev.depth === entry.depth && + prev.projectionValues === entry.projectionValues + ) + return prev; + this.entryCache.set(entry.sessionId, entry); + return entry; + }); + const itemIds = new Set(items.map((entry) => entry.sessionId)); + for (const id of this.entryCache.keys()) { + if (!itemIds.has(id)) this.entryCache.delete(id); + } + const sameOrder = + items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]); + if (!sameOrder) this.itemsCache = items; + return { + items: this.itemsCache, + state: this.listState, + phase: this.listPhase, + error: this.listError, + projectionsBySession: Object.fromEntries( + [...this.projectionStores].map(([sessionId, store]) => [ + sessionId, + { + values: store.values(), + state: "idle", + error: null, + ...this.projectionLoads.get(sessionId), + }, + ]), + ), + }; + } +} + +/** Apply one list mutation without deriving display order. */ +function applyMutation( + summaries: readonly SessionSummary[], + mutation: SessionListMutation, +): SessionSummary[] { + switch (mutation.kind) { + case "upsert": + case "placeholder": { + const existing = summaries.find( + (summary) => summary.sessionId === mutation.summary.sessionId, + ); + if (existing === undefined) return [mutation.summary, ...summaries]; + const filled: SessionSummary = { + ...existing, + // Blank only lowers: a stale true (session-added racing the local + // first send) never re-hides an already-surfaced session. + blank: existing.blank && mutation.summary.blank, + ...(mutation.kind === "upsert" + ? { + agentAvailable: mutation.summary.agentAvailable, + running: mutation.summary.running, + } + : {}), + ...(existing.cwd === undefined && mutation.summary.cwd !== undefined + ? { cwd: mutation.summary.cwd } + : {}), + ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined + ? { parentSessionId: mutation.summary.parentSessionId } + : {}), + ...(existing.origin === undefined && mutation.summary.origin !== undefined + ? { origin: mutation.summary.origin } + : {}), + }; + if ( + filled.cwd === existing.cwd && + filled.parentSessionId === existing.parentSessionId && + filled.origin === existing.origin && + filled.blank === existing.blank && + filled.agentAvailable === existing.agentAvailable && + filled.running === existing.running + ) + return [...summaries]; + return summaries.map((summary) => + summary.sessionId === mutation.summary.sessionId ? filled : summary, + ); + } + case "remove": + return summaries.filter((summary) => summary.sessionId !== mutation.sessionId); + case "status": + // Running converts display state; it does not establish durable history. + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && + (summary.running !== mutation.running || + summary.agentAvailable !== mutation.agentAvailable || + (mutation.running && summary.blank)) + ? { + ...summary, + running: mutation.running, + agentAvailable: mutation.agentAvailable, + blank: summary.blank && !mutation.running, + } + : summary, + ); + case "activity": + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary, + ); + case "engaged": + return summaries.map((summary) => + summary.sessionId === mutation.sessionId && summary.blank + ? { ...summary, blank: false } + : summary, + ); + } +} + +/** Temporary source-plane bridge while the Host contract and client project build independently. */ +function workspaceAttachSessionId(error: RemoteFailure): SessionId | undefined { + return error.code === "session/workspace-attach-failed" ? error.details.sessionId : undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts new file mode 100644 index 000000000..c4cab1013 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/notifier.ts @@ -0,0 +1,97 @@ +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; + +/** + * Batches structural updates in microtasks and stream updates by animation + * frame. Reads may rebuild a dirty snapshot without consuming the pending + * subscriber notification. + */ +export class Notifier { + private listeners = new Set<() => void>(); + private dirty = false; + private notifyPending = false; + private scheduled: "none" | "microtask" | "frame" = "none"; + private scheduleGeneration = 0; + + /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */ + constructor(private readonly rebuild: () => void) {} + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Mark the snapshot dirty and notify in a microtask. */ + markDirty(): void { + this.dirty = true; + this.notifyPending = true; + if (this.scheduled === "microtask") return; + this.schedule("microtask"); + } + + /** Mark the snapshot dirty and publish cumulative state at most once per frame. */ + markFrameDirty(): void { + this.dirty = true; + this.notifyPending = true; + if (this.scheduled !== "none") return; + this.schedule(typeof globalThis.requestAnimationFrame === "function" ? "frame" : "microtask"); + } + + /** + * Synchronous flush: controlled-input writes must notify in the same tick as + * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end. + */ + notifyNow(): void { + this.dirty = true; + this.notifyPending = true; + this.invalidateSchedule(); + this.flush(); + } + + /** + * Pre-getSnapshot check: rebuild synchronously when dirty (read path + * before first subscribe / while unobserved). Notification stays pending. + */ + ensureFresh(): void { + if (!this.dirty) return; + this.dirty = false; + this.rebuild(); + } + + private schedule(kind: "microtask" | "frame"): void { + const generation = ++this.scheduleGeneration; + this.scheduled = kind; + const publish = () => { + if (generation !== this.scheduleGeneration) return; + this.scheduled = "none"; + this.flush(); + }; + if (kind === "frame") { + globalThis.requestAnimationFrame(publish); + } else { + queueMicrotask(publish); + } + } + + private invalidateSchedule(): void { + this.scheduleGeneration++; + this.scheduled = "none"; + } + + private flush(): void { + if (!this.notifyPending) return; + if (this.listeners.size === 0) return; // lazy: dirty (if still set) rebuilds on next getSnapshot + this.notifyPending = false; + if (this.dirty) { + this.dirty = false; + this.rebuild(); + } + notifySubscribers(this.listeners, "[session-controller]"); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts new file mode 100644 index 000000000..f54073532 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -0,0 +1,260 @@ +/** + * Generic per-session projection value store (push model; see the + * session-projection subsystem page, docs/subsystems/session-projection.md): + * the host is the only computation site; the client holds finished whole + * values per key in one of two row kinds. A `sequenced` row + * (`{ value, seq }`) comes from the connected Host — a follow opening + * baseline, a Session Controller `projection` frame, a list block the Host + * computed for an attached Session — and merges under **higher seq wins** + * against other sequenced rows. A `cached` row (`{ value }`) comes from the + * session list's view of the persisted checkpoint, carries no comparable + * seq, and yields to every sequenced write. No client-side domain folding + * exists: a domain ships projection support with zero client code. Per-key + * bare observable faces feed `useProjection` (ui-renderer binds them). + */ +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import { Notifier } from "./notifier.ts"; + +// The single projection type table, typed end to end (host unit, wire block, +// client store, React hook) — the Service Definition package's pure-type outlet +// (`/types`, zero imports), never the package root: the root's dsh-agent → +// dsh-session chain would drag the host `Context.sessions` merge into the +// client program (one program must not hold both sides). No second +// client-side "views" table (rejected in the Alternatives of +// .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). +export type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; + +/** + * The fifth framework hook seat (see the session-projection subsystem page, + * docs/subsystems/session-projection.md): key-addressed + * projection reader delivered through the standard kit. `undefined` uniformly + * means capability absent — host unit unmounted, or no baseline/frame has + * carried the key yet. The selector overload mirrors useSession (per-key uSES + * binding; reference stability holds because a key's value reference changes + * only when a frame or baseline lands). + */ +export type UseProjection = { + >( + key: K, + ): SessionProjectionMap[K] | undefined; + , S>( + key: K, + selector: (value: SessionProjectionMap[K] | undefined) => S, + eq?: (a: S, b: S) => boolean, + ): S; +}; + +/** + * Follow-opening projection baseline, restated here so the + * React-free store depends only on the type table, not the wire package's + * response vocabulary. + */ +export interface ProjectionsBaseline { + /** The consistent-cut seq (equals the window tail seq by construction). */ + asOfSeq: SessionSeqCursor; + /** Whole current values by key; a registered key absent here means the capability is absent. */ + values: Readonly>; +} + +/** + * One key's row. A `sequenced` row was computed by the Host for this Session + * within the current connection generation and carries the seq it is + * consistent with; seqs of sequenced rows are comparable with each other. A + * `cached` row was viewed from the persisted projection checkpoint by a + * header-only listing without a Session: its origin cannot vouch that its + * stored seq is comparable with the log the client later opens, so it carries + * none and yields to every sequenced write. + */ +type Row = + { kind: "cached"; value: unknown } | { kind: "sequenced"; value: unknown; seq: SessionSeqCursor }; + +/** Per-key notification channel: the bare face plus its batching notifier. */ +interface Channel { + face: ObservableSnapshot; + notifier: Notifier; +} + +/** + * One session's projection values. Framework semantics, uniform across every + * key. Sequenced writes (a baseline seeds rows at its cut, a push frame + * updates one row) compare seqs among themselves: a lower-or-equal seq within + * the Host generation loses, so a replayed frame cannot regress a value and a + * stale baseline cannot overwrite a newer frame. Cached writes (the session + * list's zero-I/O block) only fill keys no sequenced row holds, and a baseline + * discards every cached row before it seeds, regardless of seq: the connected + * Session is the truth and a cached value never outranks it. A key the store + * has never seen reads `undefined` (capability absent). Faces are identity-stable + * per key (create-on-demand, cached) so the React side binds each exactly + * once; the store-level channel (`subscribeAny`) serves coarse consumers (the + * manager's list projection reads the `title` key). + */ +export class ProjectionValueStore { + private readonly rows = new Map(); + private readonly channels = new Map(); + private valuesCache: Readonly> | undefined; + /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ + private readonly anyNotifier = new Notifier(() => {}); + + /** + * Key-addressed bare observable face (the useProjection resolution path). + * Always defined — absence is an `undefined` snapshot, never a missing + * face, so a component may subscribe before the key ever carries a value. + * @param key - projection key. + * @returns the identity-stable face for this key. + */ + faceOf(key: string): ObservableSnapshot { + return this.channel(key).face; + } + + /** + * Current whole value for a key (erased framework read; typed reads go + * through `useProjection`'s map lookup). + * @param key - projection key. + * @returns the value, or undefined while the key is absent. + */ + get(key: string): unknown { + return this.rows.get(key)?.value; + } + + /** + * Read the accepted Host watermark without subscribing or copying a value. + * @param key - projection key. + * @returns the current sequence, or undefined for absent and cached values. + */ + seqOf(key: string): SessionSeqCursor | undefined { + const row = this.rows.get(key); + return row?.kind === "sequenced" ? row.seq : undefined; + } + + /** + * Read every current projection value as one reference-stable snapshot. + * @returns The same frozen value map until a row changes. + */ + values(): Readonly> { + if (this.valuesCache === undefined) { + this.valuesCache = Object.freeze( + Object.fromEntries([...this.rows].map(([key, row]) => [key, row.value])), + ); + } + return this.valuesCache; + } + + /** + * Subscribe to any-key changes (microtask-batched) — the manager's list + * rebuild channel. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribeAny(listener: () => void): () => void { + return this.anyNotifier.subscribe(listener); + } + + /** + * Apply one finished value from the Session control stream. + * @param key - projection key. + * @param value - whole value computed by the host unit. + * @param seq - the unit's watermark at emission. + */ + apply(key: string, value: unknown, seq: SessionSeqCursor): void { + const row = this.rows.get(key); + // higher seq wins among sequenced rows; replays and stale frames drop. A + // cached row has no comparable seq and always yields. + if (row?.kind === "sequenced" && seq <= row.seq) return; + this.rows.set(key, { kind: "sequenced", value, seq }); + this.changed(key); + } + + /** + * Apply one live control frame. Frames arrive in order on the control stream, so an equal seq + * is a newer value: CodexHost projections also change between journal events (token usage, + * running tool output) without advancing the durable seq. + * @param key - projection key. + * @param value - replacement value. + * @param seq - durable watermark carried by the frame. + */ + applyLive(key: string, value: unknown, seq: SessionSeqCursor): void { + const row = this.rows.get(key); + if (row?.kind === "sequenced" && seq < row.seq) return; + this.rows.set(key, { kind: "sequenced", value, seq }); + this.changed(key); + } + + /** + * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O + * view of the persisted checkpoint. A cached value lands only where no + * sequenced row exists: a connected Session has already answered for such + * a key, and the list's view of the persisted checkpoint cannot be newer + * than it. + * @param values - whole values by key viewed from the persisted checkpoint. + */ + applyCached(values: Readonly>): void { + for (const key of Object.keys(values)) { + if (this.rows.get(key)?.kind === "sequenced") continue; + this.rows.set(key, { kind: "cached", value: values[key] }); + this.changed(key); + } + } + + /** + * Seed from a history tail page's projections block. Every cached row is + * discarded first, regardless of seq: the block comes from the connected + * Session, and a value viewed from the persisted checkpoint never outranks + * it. Then every carried key lands under the same seq rule as frames, and a + * key the block omits is capability-absent as of the cut — its row clears + * unless a newer frame already superseded the cut (a stale baseline can + * neither overwrite nor clear newer sequenced values). + * @param baseline - the response's projections block. + */ + seed(baseline: ProjectionsBaseline): void { + for (const [key, row] of this.rows) { + if (row.kind !== "cached") continue; + this.rows.delete(key); + this.changed(key); + } + // Erased walk: the framework crosses the open key space; per-key typing + // is re-established at the consumer (useProjection's map lookup). + const values = baseline.values as Record; + for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq); + for (const [key, row] of this.rows) { + if (Object.hasOwn(values, key)) continue; + // Every cached row was deleted above; the kind test only narrows the + // type so `row.seq` is readable. + if (row.kind === "sequenced" && row.seq > baseline.asOfSeq) continue; + this.rows.delete(key); + this.changed(key); + } + } + + /** Discard one Host generation's values and watermarks while preserving subscribed faces. */ + clear(): void { + for (const key of this.rows.keys()) { + this.rows.delete(key); + this.changed(key); + } + } + + private changed(key: string): void { + this.valuesCache = undefined; + this.channels.get(key)?.notifier.markDirty(); + this.anyNotifier.markDirty(); + } + + private channel(key: string): Channel { + let channel = this.channels.get(key); + if (channel === undefined) { + // The notifier only batches (no snapshot cache to rebuild: faces read rows directly). + const notifier = new Notifier(() => {}); + channel = { + notifier, + face: { + getSnapshot: () => this.rows.get(key)?.value, + subscribe: (listener) => notifier.subscribe(listener), + }, + }; + this.channels.set(key, channel); + } + return channel; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts new file mode 100644 index 000000000..53af4904e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/remotes.ts @@ -0,0 +1,48 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes + */ + +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +import type { CommandSubmitAttachment } from "@deepseek-ai/dsh-commands/types"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + SubagentInterruptReceipt, + SubagentPromptReceipt, + SubagentPromptRequest, +} from "@deepseek-ai/dsh-subagent/client"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRemote } from "../transport.ts"; + +/** Narrow Commands namespace consumed by a Client Session. */ +export interface SessionCommandsRemote { + execute( + agentId: SessionId, + line: string, + attachments: readonly CommandSubmitAttachment[], + signal?: AbortSignal, + ): Promise>; +} + +/** Narrow subagent namespace consumed by a Client Session and its manager. */ +export interface SessionSubagentsRemote { + prompt( + request: SubagentPromptRequest, + signal?: AbortSignal, + ): Promise>; + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: "continuable", + ): Promise>; +} + +/** Generated Remote namespaces consumed by the Client Session object layer. */ +export interface SessionRemotes { + readonly $stream: ClientRemote["$stream"]; + readonly commands: SessionCommandsRemote; + readonly session: SessionRemote; + readonly subagents: SessionSubagentsRemote; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts new file mode 100644 index 000000000..11997cd72 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/service.ts @@ -0,0 +1,779 @@ +/** Client catalog and source-labelled ownership of exact Session generations. */ +import type { Context, Fiber } from "@deepseek-ai/cordis"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import { SessionSeq, type SessionId } from "@deepseek-ai/dsh-session/types"; +import { workspaceTitleOf } from "@deepseek-ai/dsh-util-workspace-path"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.ts"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import { + createSnapshotStore, + notifySubscribers, + type ObservableSnapshot, + type SnapshotStore, +} from "@deepseek-ai/dsh-client-store"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionEventSource } from "../contract/events.ts"; +import type { SessionFace } from "../contract/session.ts"; +import type { + AgentContext, + ISessions, + SessionReference, + SessionRetainInfo, + SessionRetainOptions, + SessionTarget, +} from "../contract/sessions.ts"; +import type { SessionReferenceSource } from "../index.ts"; +import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from "../scope.ts"; +import { SessionManager } from "./manager.ts"; +import type { SessionRemotes } from "./remotes.ts"; +import type { + SessionListPhase, + SessionSearchResultItem, + SessionProjectionSnapshot, +} from "./manager.ts"; +import type { Session } from "./session.ts"; + +/** Session list row projected from the host list RPC plus live stream increments. */ +export interface SessionSummary { + id: SessionId; + /** Latest durable log-backed title, absent until the host projects one. */ + title?: string; + /** Human-facing label: durable title, project basename, then session id. */ + displayTitle: string; + cwd?: string; + parentId?: SessionId; + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: "subagent"; + /** Host running state for `ids` members; a display fallback for other rows. */ + running: boolean; + /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ + readonly retainedBy: SessionRetainInfo["retainedBy"]; + /** + * New Session presentation and reuse eligibility, derived from the Host + * summary, `sessionListMetadata`, and client acceptance/running observations. + * New Session reuses a blank one targeting the same workspace. Filtering + * stays with the consumer: the store carries every row, while the Workspace + * browser shows only the selected blank entry. + */ + blank: boolean; + updatedAt: number; + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly>; +} + +/** Catalog metadata and local source counts; catalog membership owns no Client generation. */ +export interface SessionListState { + /** Host list order; every id has a matching byId row in the same snapshot. */ + ids: SessionId[]; + /** Host/catalog rows plus retained subagent fallbacks; only `ids` expresses Host-list membership. */ + byId: Record; + /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ + phase: SessionListPhase; + /** Shared projection values and explicit-read state, including unopened Sessions. */ + projectionsBySession: Readonly>; +} + +/** Structured session-create failure. */ +export class SessionCreateError extends Error { + override readonly name = "SessionCreateError"; + + /** + * @param rpcError - Host business or folded transport error. + * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. + */ + constructor( + readonly rpcError: RemoteFailure, + readonly requestedSessionId: SessionId | undefined, + ) { + super(`session create failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Structured session-fork failure. */ +export class SessionForkError extends Error { + override readonly name = "SessionForkError"; + + /** + * @param rpcError - Host business or folded transport error. + * @param sourceSessionId - the session the fork was cut from. + */ + constructor( + readonly rpcError: RemoteFailure, + readonly sourceSessionId: SessionId, + ) { + super(`session fork failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Identity-stable logical binding for one materialized Client Session. */ +export interface SessionBinding { + readonly sessionId: SessionId; + /** The outward session face only — feature code never sees the concrete class. */ + readonly session: SessionFace; + /** Contiguous event window reserved for Conversation assembly. */ + readonly eventSource: SessionEventSource; + readonly ctx: AgentContext; +} + +// Scope primitives live in ../scope.ts (the client mirror of host +// dsh-scope, keyed by Agent identity); re-exported here so existing +// consumers keep their import site. +export { scopeOf } from "../scope.ts"; + +/** + * Display title projection: durable title, project directory basename, then + * the raw id. + */ +function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { + if (title !== undefined) return title; + if (cwd !== undefined && cwd !== "") { + const base = workspaceTitleOf(cwd); + if (base !== "") return base; + } + return id; +} + +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - source session's durable title. + * @returns the title assigned to the fork child. + */ +function increasedForkTitle(title: string): string { + const ascii = /^(.*?)\((\d+)\)$/u.exec(title); + if (ascii?.[1] !== undefined && ascii[2] !== undefined) { + return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`; + } + const fullWidth = /^(.*?)((\d+))$/u.exec(title); + if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) { + return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`; + } + return `${title} (1)`; +} + +/** Source labels are dictionary keys, including names also present on Object.prototype. */ +function freezeRetainedBy( + counts: Partial>, +): SessionRetainInfo["retainedBy"] { + Object.setPrototypeOf(counts, null); + return Object.freeze(counts); +} + +const EMPTY_RETAIN_INFO: SessionRetainInfo = Object.freeze({ + referenceCount: 0, + retainedBy: freezeRetainedBy({}), +}); + +interface ScopeRecord { + fiber: Fiber; + ctx: AgentContext; + binding: SessionBinding; + session: Session; + retention: SessionRetainInfo; + live: boolean; +} + +interface RetentionObserver { + readonly source: ObservableSnapshot; + readonly listeners: Set<() => void>; + published: SessionRetainInfo; +} + +/** A cancelled waiter releases only its own reference, not the shared opening. */ +async function waitForOpen(opening: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return opening; + const aborted = Promise.withResolvers(); + const onAbort = (): void => { + aborted.reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + if (signal.aborted) onAbort(); + await Promise.race([opening, aborted.promise]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +class ClientSessionReference implements SessionReference { + private readonly released = new AbortController(); + private readonly readiness = Promise.withResolvers(); + readonly ready = this.readiness.promise; + + constructor( + readonly sessionId: SessionId, + private record: ScopeRecord | undefined, + private releaseReference: (() => void) | undefined, + ) { + void this.ready.catch(() => {}); + } + + get binding(): SessionBinding { + if (this.record === undefined || !this.record.live) + throw new Error(`Session reference "${this.sessionId}" is released`); + return this.record.binding; + } + + attachOpening(opening: Promise, signal?: AbortSignal): void { + const waitSignal = + signal === undefined ? this.released.signal : AbortSignal.any([this.released.signal, signal]); + void waitForOpen(opening, waitSignal).then( + () => { + try { + waitSignal.throwIfAborted(); + this.readiness.resolve(this.binding); + } catch (error: unknown) { + this.readiness.reject(error); + } + }, + (error: unknown) => { + this.readiness.reject(error); + }, + ); + } + + release(): void { + const reason = new Error(`Session reference "${this.sessionId}" is released`); + const release = this.releaseReference; + this.released.abort(reason); + this.readiness.reject(reason); + this.record = undefined; + this.releaseReference = undefined; + release?.(); + } + + [Symbol.dispose](): void { + this.release(); + } +} + +/** Host catalog and local reference allocator; view selection remains outside the Controller. */ +export class ClientSessions implements ISessions { + /** + * The wire schema's own result bound, re-exposed for presentation plugins as + * injected data. Not per-connection state: the `session.search` response + * schema caps `items` at this constant, so every transport (fixture included) + * reports the same number. + */ + readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT; + /** Catalog metadata and local reference-source projection. */ + readonly list: SnapshotStore; + /** The object-layer instance cluster and frame dispatch entry. */ + private readonly manager: SessionManager; + private readonly scopes = new Map(); + /** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */ + private readonly retainObservers = new Map(); + private readonly scopeDrops = new Set>(); + private closed = false; + + /** + * @param ctx - client root context (scope fibers mount under it). + * @param remote - generated Remote namespaces shared with every Session. + */ + constructor( + private readonly rootCtx: Context, + remote: SessionRemotes, + ) { + this.manager = new SessionManager(remote); + this.list = createSnapshotStore({ + ids: [], + byId: {}, + phase: "pending", + projectionsBySession: {}, + }); + const disposeManagerProjection = this.manager.subscribe(() => { + this.projectList(); + }); + rootCtx.effect( + () => async () => { + this.closed = true; + disposeManagerProjection(); + const scopes = [...this.scopes]; + this.scopes.clear(); + for (const [, record] of scopes) { + record.live = false; + record.session.unbindScope(); + } + const managerDisposal = this.manager.dispose(); + for (const [id, record] of scopes) { + this.startScopeDrop(id, record); + this.publishRetention(id); + } + await this.drainScopeDrops(); + await managerDisposal; + }, + "session-controller.client.sessions", + ); + rootCtx.reflect.provide("sessions", this, undefined); + } + + retain(target: SessionTarget, options: SessionRetainOptions): SessionReference { + const { source, signal } = options; + signal?.throwIfAborted(); + if (this.closed) throw new Error("Session Controller is disposed"); + const id = this.manager.resolveTarget(target); + const reference = this.retainScope(id, source); + try { + reference.attachOpening(this.manager.get(id).open(), signal); + return reference; + } catch (error) { + reference.release(); + throw error; + } + } + + async using( + target: SessionTarget, + options: SessionRetainOptions, + operation: (reference: SessionReference) => T | Promise, + ): Promise { + const reference = this.retain(target, options); + try { + await reference.ready; + return await operation(reference); + } finally { + reference.release(); + } + } + + retainInfo(id: SessionId): ObservableSnapshot { + let observer = this.retainObservers.get(id); + if (observer === undefined) { + const listeners = new Set<() => void>(); + observer = { + listeners, + published: this.retentionSnapshot(id), + source: { + getSnapshot: () => this.retentionSnapshot(id), + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; + this.retainObservers.set(id, observer); + } + return observer.source; + } + + /** + * Resolve an already discovered direct-parent address without opening it. + * Feature plugins use this to avoid Agent-bound RPCs in persisted child views. + * @param id - possible addressed child id. + * @returns A retained or loaded-catalog address, without retaining a new selection or scope. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined { + return this.manager.subagentAddress(id); + } + + /** + * Load all Session projections once per connection; retry an unsuccessful initial read. + * @param sessionId - Session to inspect without opening its conversation. + */ + refreshProjections(sessionId: SessionId): Promise { + return this.manager.refreshProjections(sessionId); + } + + /** + * Refresh the real Session baseline, reusing an in-flight pull. + * @returns completion of the current or newly started baseline pull. + */ + refresh(): Promise { + return this.manager.refreshList(); + } + + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> { + return this.manager.search(query, signal); + } + + /** + * Apply one Session Controller live-control frame. + * @param frame - baseline or live control replacement. + */ + handleControlFrame(frame: Parameters[0]): void { + this.manager.handleControlFrame(frame); + } + + /** + * Apply one remotely forwarded Session-list addition. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: Parameters[0]): void { + this.manager.handleSessionAdded(summary); + } + + /** + * Apply one remotely forwarded Session removal. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: Parameters[0]): void { + this.manager.handleSessionRemoved(sessionId); + } + + /** + * Apply one remotely forwarded running-state change. + * @param args - Session identity and current Agent running state. + */ + handleSessionStatus(...args: Parameters): void { + this.manager.handleSessionStatus(...args); + } + + /** + * Apply one remotely forwarded list-activity change. + * @param args - Session identity and durable activity timestamp. + */ + handleSessionActivity(...args: Parameters): void { + this.manager.handleSessionActivity(...args); + } + + /** + * Apply one remotely forwarded Agent failure. + * @param args - Session identity and caller-visible failure description. + */ + handleSessionError(...args: Parameters): void { + this.manager.handleSessionError(...args); + } + + /** Rebuild the Session baseline and every opened window after connection. */ + handleConnected(): void { + this.manager.handleConnected(); + } + + /** + * Create a Host Session and publish its catalog row before resolving. + * Callers retain the returned identity before borrowing its binding. + * @param opts - target workspace or directory and an optional preallocated id. + * @returns the new session id. + * @throws {SessionCreateError} with the requested id. + */ + async create( + opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + ): Promise { + const result = await this.manager.create(opts); + if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId); + this.projectList(); + return result.value.sessionId; + } + + /** + * Fork a session from an exact inclusive prefix of the source (same + * synchronous-addressability guarantee as {@link ClientSessions.create}: + * on resolution the child is catalogued and may be explicitly retained). + * @param opts - source session id, the optional exact inclusive boundary + * seq (a real event seq the caller already knows; a cut inside an open + * turn is balanced Host-side with synthetic closers, and omission selects + * the latest completed-turn prefix), and whether to increment an + * inherited durable title before resolving. + * @returns the child session id. + * @throws {SessionForkError} with the source id. + * @throws {Error} when a requested child-title rename fails after creation. + */ + async fork(opts: { + sessionId: SessionId; + atSeq?: number; + increaseTitle?: boolean; + onCreated?: (childId: SessionId) => void; + }): Promise { + const sourceTitle = opts.increaseTitle + ? this.list.getSnapshot().byId[opts.sessionId]?.title + : undefined; + const result = await this.manager.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }), + }); + if (!result.ok) throw new SessionForkError(result.error, opts.sessionId); + this.projectList(); + const childId = result.value.sessionId; + opts.onCreated?.(childId); + if (sourceTitle !== undefined) { + const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle)); + if (!renamed.ok) + throw new Error( + `fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`, + ); + } + return childId; + } + + /** + * Borrow an already-retained Agent-scoped Context. + * @param id - session id (the agent identity — 1:1 same axis). + * @returns the scoped Context, or undefined without a retained generation. + */ + scope(id: SessionId): AgentContext | undefined { + return this.scopes.get(id)?.ctx; + } + + /** + * Retain a validated Gateway identity synchronously, without history or catalog I/O. + * @param id - Host-projected Session identity, possibly not yet catalogued. + * @returns a Gateway-source reference owned by the invocation. + */ + retainAgentScope(id: SessionId): SessionReference { + if (this.closed) throw new Error("Session Controller is disposed"); + return this.retainScope(id, "gateway"); + } + + /** + * Read the Agent scope tag off a context. Service-method boundary: fetch + * bundles must reach scope resolution through ctx.sessions — a cross-bundle + * value import of the standalone helper would inline a second module + * instance whose private tag Symbol never matches. + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined { + return scopeTagOf(ctx); + } + + /** + * Resolve the business Session behind an Agent-scoped context — the one + * hop every scoped consumer (event listeners, per-session controllers) + * takes from ctx-space into object-space (the client mirror of host + * `agent.session`). Same service-method boundary as + * {@link ClientSessions.scopeOf}. + * @param ctx - an Agent-scoped context. + * @returns the matching live Session, or undefined for an untagged or ended generation. + */ + sessionOf(ctx: Context): SessionFace | undefined { + const id = scopeTagOf(ctx); + if (id === undefined) return undefined; + const record = this.scopes.get(id); + return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx) + ? record.binding.session + : undefined; + } + + /** + * Borrow an already-retained binding without extending its lifetime. + * @param id - Session identity. + * @returns the live binding, or undefined without a retained generation. + */ + binding(id: SessionId): SessionBinding | undefined { + return this.scopes.get(id)?.binding; + } + + private retainScope(id: SessionId, source: SessionReferenceSource): ClientSessionReference { + const record = this.scopes.get(id) ?? this.materializeScope(id); + const previous = record.retention; + record.retention = Object.freeze({ + referenceCount: previous.referenceCount + 1, + retainedBy: freezeRetainedBy({ + ...previous.retainedBy, + [source]: (previous.retainedBy[source] ?? 0) + 1, + }), + }); + const reference = new ClientSessionReference(id, record, () => { + if (!record.live) return; + const count = record.retention.referenceCount - 1; + const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy; + const retainedBy = + sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources; + record.retention = + count === 0 + ? EMPTY_RETAIN_INFO + : Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) }); + if (count === 0) this.retireScope(id, record); + else this.publishRetention(id); + }); + if ( + this.list.getSnapshot().byId[id] === undefined && + this.manager.subagentAddress(id) !== undefined + ) { + this.projectList(); + } + this.publishRetention(id); + return reference; + } + + private retentionSnapshot(id: SessionId): SessionRetainInfo { + return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO; + } + + private publishRetention(id: SessionId): void { + const state = this.list.getSnapshot(); + const row = state.byId[id]; + const retainedBy = this.retentionSnapshot(id).retainedBy; + if (row !== undefined && row.retainedBy !== retainedBy) { + this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } }); + } + const observer = this.retainObservers.get(id); + const snapshot = this.retentionSnapshot(id); + if (observer === undefined || observer.published === snapshot) return; + observer.published = snapshot; + notifySubscribers(observer.listeners, "[session-controller] reference sources"); + } + + private retireScope(id: SessionId, record: ScopeRecord, disposeFiber = true): void { + if (!record.live) return; + record.live = false; + if (this.scopes.get(id) === record) this.scopes.delete(id); + record.session.unbindScope(); + const sessionDisposal = this.manager.drop(id, record.session); + this.projectList(); + this.publishRetention(id); + this.startScopeDrop(id, record, disposeFiber, sessionDisposal); + } + + /** Materialize one scope after its caller establishes that the id may be addressed. */ + private materializeScope(id: SessionId): ScopeRecord { + const { fiber, ctx } = createScope(this.rootCtx, id); + const session = this.manager.get(id); + // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); + // mint and bind are one step so a live scope record implies a bound actx. + session.bindScope(ctx); + const binding: SessionBinding = { + sessionId: id, + session, + eventSource: session.eventSource, + ctx, + }; + const record: ScopeRecord = { + fiber, + ctx, + binding, + session, + retention: EMPTY_RETAIN_INFO, + live: true, + }; + this.scopes.set(id, record); + ctx.effect( + () => () => { + this.retireScope(id, record, false); + }, + "session-controller: exact generation", + ); + return record; + } + + /** Project the manager's list snapshot into the store (title derivation is display-only). */ + private projectList(): void { + const previousById = this.list.getSnapshot().byId; + const { items, phase, projectionsBySession } = this.manager.getListSnapshot(); + const ids: SessionId[] = []; + const byId: Record = {}; + for (const entry of items) { + ids.push(entry.sessionId); + byId[entry.sessionId] = { + id: entry.sessionId, + displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), + running: entry.running, + retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy, + blank: entry.blank, + updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), + ...(entry.title !== undefined ? { title: entry.title } : {}), + ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), + ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + }; + } + for (const [parentId, projection] of Object.entries(projectionsBySession)) { + for (const child of projection.values.subagentCatalog ?? []) { + const childId = child.id; + const summary = byId[childId]; + const projectionValues = + summary?.projectionValues ?? this.manager.projectionValues(childId); + const projectedTitle = projectionValues?.title; + const title = + typeof projectedTitle === "string" && projectedTitle !== "" ? projectedTitle : undefined; + const displayTitle = title ?? child.label ?? childId; + if (summary === undefined) { + byId[childId] = { + id: childId, + displayTitle, + parentId: parentId as SessionId, + origin: "subagent", + running: this.scopes.get(childId)?.session.getSnapshot().running ?? false, + blank: false, + updatedAt: 0, + retainedBy: this.retentionSnapshot(childId).retainedBy, + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title }), + }; + } else if ( + summary.displayTitle !== displayTitle || + summary.projectionValues !== projectionValues + ) { + byId[childId] = { + ...summary, + displayTitle, + ...(projectionValues === undefined ? {} : { projectionValues }), + }; + } + } + } + for (const [id, record] of this.scopes) { + if (byId[id] !== undefined) continue; + const address = this.manager.subagentAddress(id); + if (address === undefined) continue; + const previous = previousById[id]; + const snapshot = record.session.getSnapshot(); + const projectionValues = this.manager.projectionValues(id); + const projectedTitle = projectionValues?.title; + const title = + typeof projectedTitle === "string" && projectedTitle !== "" + ? projectedTitle + : previous?.title; + byId[id] = { + ...(previous ?? { id, displayTitle: id, updatedAt: 0 }), + running: snapshot.running, + retainedBy: record.retention.retainedBy, + blank: snapshot.blank, + parentId: address.parentSessionId, + origin: "subagent", + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title, displayTitle: title }), + }; + } + this.list.set({ ids, byId, phase, projectionsBySession }); + } + + private startScopeDrop( + id: SessionId, + record: ScopeRecord, + disposeFiber = true, + sessionDisposal = this.manager.drop(id, record.session), + ): void { + const drop = this.dropScope(record, disposeFiber, sessionDisposal); + this.scopeDrops.add(drop); + void drop.then( + () => { + this.scopeDrops.delete(drop); + }, + () => { + this.scopeDrops.delete(drop); + }, + ); + } + + private async drainScopeDrops(): Promise { + while (this.scopeDrops.size > 0) { + await Promise.allSettled([...this.scopeDrops]); + } + } + + /** Await the already-withdrawn Session and scoped cleanup to quiescence. */ + private async dropScope( + record: ScopeRecord, + disposeFiber: boolean, + sessionDisposal: Promise, + ): Promise { + await Promise.allSettled([sessionDisposal, ...(disposeFiber ? [record.fiber.dispose()] : [])]); + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts new file mode 100644 index 000000000..ec27dfff1 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts @@ -0,0 +1,1038 @@ +// Sessions remain resident after creation so their open Remote sources keep running off-screen. + +import type { Context } from "@deepseek-ai/cordis"; +import type { InboxState, InboxTarget } from "@deepseek-ai/dsh-agent/types"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import type { + AttachmentIdType, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { SubagentAddress } from "@deepseek-ai/dsh-subagent/client"; +import type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { UserMessage } from "@deepseek-ai/dsh-llm/types"; +import { SessionLogOffset, SessionSeq, type SessionId } from "@deepseek-ai/dsh-session/types"; +import { SessionEventStream } from "../transport.ts"; +import type { SessionJournalChange } from "../transport.ts"; +import type { + PromptContentPart, + QueueAction, + SessionAddress, + SessionAssistantStreamBaseline, + SessionProjectionBaseline, + SessionRequestId, +} from "../../types.ts"; +import type { + BeginSubmissionInput, + PendingSubmissionRetirement, + SessionFace, + SubmissionHandle, +} from "../contract/session.ts"; +import type { + OpenState, + PendingSubmission, + PromptError, + SessionSnapshot, +} from "../contract/snapshot.ts"; +import { MutableSessionEventSource } from "../contract/events.ts"; +import type { + SessionEventLike, + SessionEventLikeEntry, + SessionLiveEventEntry, +} from "../contract/events.ts"; +import { Notifier } from "./notifier.ts"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteFailure, RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionRemotes } from "./remotes.ts"; +import { ProjectionValueStore } from "./projection-store.ts"; +import type { ProjectionsBaseline } from "./projection-store.ts"; +import { resolvedClientTimeZone } from "../time-zone.ts"; +import { ClientAssistantStream, type ClientAssistantStreamResult } from "./assistant-stream.ts"; + +function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline { + return { + ...value, + asOfSeq: value.asOfSeq === -1 ? -1 : SessionSeq(value.asOfSeq), + }; +} + +/** Minimum message count for ordinary history windows. */ +export const PAGE_MESSAGES = 50; + +const HISTORY_PAGE_OPTIONS = { + maxMessages: 500, + turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 }, +}; + +/** Minimum messages per page while a turn jump loops backwards. */ +export const JUMP_PAGE_MESSAGES = 200; + +const JUMP_PAGE_OPTIONS = { + ...HISTORY_PAGE_OPTIONS, + turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES }, +}; + +interface PendingHistory { + beforeSeq: SessionLogOffset; + hasMore: boolean; + readonly pages: (readonly SessionEventLikeEntry[])[]; +} + +/** Manager-owned observers of a Session object's local state edges. */ +export interface SessionOptions { + /** Catalog-discovered address selecting non-activating subagent transport. */ + address?: SubagentAddress; + /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ + parentAvailable?: boolean; + /** + * Publish each accepted prompt to the Manager, including after this Session + * object is replaced. Acceptance converts display state, but does not + * establish that a turn started or reached durable history. + */ + onEngaged?(session: Session): void; + /** + * Manager-owned projection value store to adopt (frames route through the + * manager and values outlive instantiation); omitted, the Session owns a + * private store (bare object-layer construction). + */ + projections?: ProjectionValueStore; +} + +/** + * Owns a session's event window, lifecycle state, and observable + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are Session Controller internals. + */ +export class Session implements SessionFace { + // ---- Window and derived state (all private; the snapshot is the only read API) ---- + private baseSeq = SessionLogOffset(0); + private hasMore = false; + private openState: OpenState = "cold"; + private openError: RemoteFailure | null = null; + private openPromise: Promise | null = null; + /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale + * passes drop all writes once the generation moves on. */ + private openGeneration = 0; + private loadingOlder = false; + /** Shared low-water target of the running jump loop; null when no jump is paging. */ + private jumpTargetSeq: SessionSeq | null = null; + /** The running jump loop's completion, shared by retargeting callers. */ + private jumpPromise: Promise | null = null; + private pendingHistory: PendingHistory | null = null; + private readonly stopObservingInbox: () => void; + private readonly assistantStream = new ClientAssistantStream(); + private running = false; + private address: SubagentAddress | undefined; + private parentAvailable: boolean | undefined; + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false; + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false; + /** New Session display state; unknown bare sessions begin conservatively blank. */ + private blankBit = true; + private removed = false; + private promptError: PromptError | null = null; + private lastAgentError: string | null = null; + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = []; + /** Per-echo settlement state; `retiring` latches the first observation so a + * Inbox projection and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map< + SessionRequestId, + { + readonly placement: PendingSubmission["placement"]; + /** Latest received Inbox position; null means removed from that queue. */ + receipt?: { + readonly target: InboxTarget; + readonly seq: number; + readonly index: number | null; + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + }; + /** Durable admission may precede the Inbox projection acknowledging its claim. */ + admitted?: readonly (ImageAttachmentRef | FileAttachmentRef)[]; + readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined; + retiring: boolean; + } + >(); + /** Owns the addressed page/follow lifecycle while this Session is open. */ + private events: SessionEventStream | undefined; + + /** + * Per-session projection value store (push model; see the session-projection + * subsystem page, docs/subsystems/session-projection.md): finished whole + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames; + * Host-sequenced writes merge under higher-seq-wins and cached list blocks + * yield to them (projection-store.ts). Keys are read via `projections.faceOf(key)` + * (the useProjection resolution face); the conversation snapshot never + * carries projection values, and no client-side domain folding exists. + * Manager-owned when constructed through SessionManager (frames route and + * the store outlives instantiation, the title-snapshot precedent); a bare + * construction gets a private store. + */ + readonly projections: ProjectionValueStore; + + /** Contiguous history and live tail consumed by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource(); + private snapshotCache: SessionSnapshot; + private readonly notifier: Notifier; + /** + * Agent-scoped cordis context, bound once by ClientSessions when it + * mints the scope (the client mirror of the host Agent's loopCtx). The + * Session dispatches its own scoped events through it; undefined means + * unbound (bare object-layer construction) or already pruned — both skip + * dispatch-dependent behavior rather than fail. + */ + private actx: Context | undefined; + + /** + * @param sessionId - Host session identity (client sessions are always Host-born). + * @param remote - generated Remote namespaces this session calls. + * @param options - optional manager-owned state observers. + */ + constructor( + readonly sessionId: SessionId, + private readonly remote: SessionRemotes, + private readonly options: SessionOptions = {}, + ) { + this.projections = options.projections ?? new ProjectionValueStore(); + this.address = options.address; + this.parentAvailable = options.parentAvailable; + this.notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot(); + }); + this.snapshotCache = this.buildSnapshot(); + this.stopObservingInbox = this.projections.faceOf("inbox").subscribe(() => { + this.observeSubmissionInbox(); + }); + } + + /** + * Bind the Agent-scoped context minted by ClientSessions (single write; + * a second bind is a wiring error and throws). Direction stays one-way at + * this binding boundary: consumers still reach the Session via `sessions.sessionOf`, + * while the Session holds its own dispatch point (host Agent.loopCtx + * mirror). + * @param actx - the agent's scoped context. + */ + bindScope(actx: Context): void { + if (this.actx !== undefined) + throw new Error(`session ${this.sessionId} already has a bound scope`); + this.actx = actx; + } + + /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ + unbindScope(): void { + this.actx = undefined; + } + + // ---- Operations ---- + + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId; + const placement = this.running + ? input.mode === "steer" + ? "steering" + : "queued" + : "transcript"; + this.pendingSubmissions = [ + ...this.pendingSubmissions, + { + requestId, + placement, + time: Date.now(), + text: input.text, + attachments: input.attachments, + }, + ]; + this.submissionSettlements.set(requestId, { + placement, + onRetire: input.onRetire, + retiring: false, + }); + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true; + this.notifier.markDirty(); + return { + requestId, + abandon: () => { + this.retireFailedSubmission(requestId); + }, + }; + } + + /** + * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. + * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. + * @returns the prompt result (also mirrored into promptError on failure). + */ + async prompt( + content: PromptContentPart[], + mode: "queue" | "steer", + signal?: AbortSignal, + requestId?: SessionRequestId, + ): Promise> { + this.promptError = null; + this.lastAgentError = null; + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true; + if (this.blankBit) this.firstPromptPendingTurn = true; + this.notifier.markDirty(); + let result: RemoteResult<{ accepted: true }>; + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone(); + result = await this.remote.session.prompt( + { + requestId: requestId ?? (randomUUID() as SessionRequestId), + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, + signal, + ); + } else if (content.some((part) => part.type === "file")) { + result = { + ok: false, + error: new RemoteError( + "subagent/attachment-invalid", + "subagent continuation does not accept files", + { reason: "SUBAGENT_FILE_UNSUPPORTED" }, + ), + }; + } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[]; + const routed = await this.remote.subagents.prompt( + { + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: "continuable", + delivery: mode, + content: routedContent, + clientTimeZone: resolvedClientTimeZone(), + }, + signal, + ); + result = routed.ok ? { ok: true, value: { accepted: true } } : routed; + } + if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId); + this.promptError = { op: "send", error: result.error }; + this.notifier.markDirty(); + return result; + } + // Rejection must leave a first prompt blank and eligible for workspace reuse. + if (this.blankBit) { + this.blankBit = false; + this.notifier.markDirty(); + } + this.options.onEngaged?.(this); + return result; + } + + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment( + attachmentId: AttachmentIdType, + ): Promise> { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }); + if (!result.ok) return result; + const binary = atob(result.value.data); + const data = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return { ok: true, value: { attachment: result.value.attachment, data } }; + } + + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue( + itemId: MessageId, + action: QueueAction, + ): Promise> { + return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }); + } + + /** + * Stop the active turn while the Host preserves pending inbox work; failures + * land in promptError (same error-strip display slot). A subagent address + * routes through `subagents.interruptByParent`, whose durable parent-address + * authority works without a live parent Agent. + * @returns the cancel result. + */ + async cancel(): Promise> { + const address = this.address; + const result = + address !== undefined + ? await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + "continuable", + ) + : await this.remote.session.cancel({ sessionId: this.sessionId }); + if (!result.ok) { + this.promptError = { op: "stop", error: result.error }; + this.notifier.markDirty(); + } + return result; + } + + /** + * Rename: contract session.rename 1:1. On success settle the 'title' + * projection cell from the response's `{title, seq}` under the store's + * higher-seq-wins rule (the push frame arriving later is a no-op replay), + * so the list row and any useProjection('title') reader update without + * waiting for the control-stream projection update. + * @param title - raw title text (the host normalizes acceptance). + * @returns the rename result (normalized accepted title + title event seq). + */ + async rename(title: string): Promise> { + const result = await this.remote.session.rename({ sessionId: this.sessionId, title }); + if (!result.ok) return result; + const seq = SessionSeq(result.value.seq); + this.projections.apply("title", result.value.title, seq); + return { ok: true, value: { title: result.value.title, seq } }; + } + + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result. + */ + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line, []); + if (!result.ok) return result; + return { ok: true, value: { matched: result.value !== undefined } }; + } + + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ + open(): Promise { + if (this.openState === "open") return Promise.resolve(); + if (this.openPromise !== null) return this.openPromise; + const promise = this.doOpen(this.openGeneration).finally(() => { + // Identity-guarded: a superseded open must not null out the promise resync just started. + if (this.openPromise === promise) this.openPromise = null; + }); + this.openPromise = promise; + return promise; + } + + /** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */ + async loadOlder(): Promise { + if (this.openState !== "open" || !this.hasMore || this.loadingOlder) return; + const events = this.events; + if (events === undefined) return; + this.loadingOlder = true; + this.notifier.markDirty(); + try { + await events.prepend({ + beforeSeq: this.baseSeq, + ...HISTORY_PAGE_OPTIONS, + }); + } catch (error) { + if (!isRemoteFailure(error)) { + console.error("[session-controller] loadOlder failed:", error); + } + } finally { + this.loadingOlder = false; + this.notifier.markDirty(); + } + } + + /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */ + loadThrough(seq: SessionSeq): Promise { + if (this.openState !== "open" || !this.hasMore || this.baseSeq <= seq) return Promise.resolve(); + if (this.jumpPromise !== null) { + // Retarget the running loop to the lowest requested seq. + this.jumpTargetSeq = SessionSeq(Math.min(this.jumpTargetSeq ?? seq, seq)); + return this.jumpPromise; + } + // A plain single-page pull owns the busy flag; the jump does not queue + // behind it (the caller retries once it settles) and must leave no + // target behind — only the loop's finally clears that field, and no + // loop starts here. + if (this.loadingOlder) return Promise.resolve(); + const events = this.events; + if (events === undefined) return Promise.resolve(); + const pending: PendingHistory = { + beforeSeq: this.baseSeq, + hasMore: this.hasMore, + pages: [], + }; + this.pendingHistory = pending; + this.jumpTargetSeq = seq; + this.loadingOlder = true; + this.notifier.markDirty(); + // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the + // stream generation; this pass then stops instead of paging the new + // generation toward its old target. + const generation = this.openGeneration; + this.jumpPromise = (async () => { + try { + while ( + pending.hasMore && + this.jumpTargetSeq !== null && + pending.beforeSeq > this.jumpTargetSeq + ) { + if (generation !== this.openGeneration) return; + const before = pending.beforeSeq; + await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS }); + // No-progress guard: an empty or dropped page that still claims more + // history must end the loop, not spin it. + if (pending.beforeSeq >= before) return; + } + } catch (error) { + if (!isRemoteFailure(error)) { + console.error("[session-controller] loadThrough failed:", error); + } + } finally { + this.jumpTargetSeq = null; + this.jumpPromise = null; + this.pendingHistory = null; + this.loadingOlder = false; + if (generation === this.openGeneration && pending.pages.length > 0) { + this.prependWindow(pending.pages.reverse().flat(), pending.hasMore); + } + this.notifier.markDirty(); + } + })(); + return this.jumpPromise; + } + + /** Rebuild an opened history source after address replacement. + * Invalidates any in-flight open first; projection state belongs to the independently + * reconnecting control stream and remains untouched. */ + async resync(): Promise { + if (this.openState === "cold") return; // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) + this.openGeneration++; + const events = this.events; + this.events = undefined; + await events?.dispose(); + this.openPromise = null; + this.openState = "cold"; + this.openError = null; + this.baseSeq = SessionLogOffset(0); + this.notifier.markDirty(); + await this.open(); + } + + // ---- Subscription API (useSyncExternalStore direct wiring) ---- + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener); + } + + /** + * Cached Session snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getSnapshot(): SessionSnapshot { + this.notifier.ensureFresh(); + return this.snapshotCache; + } + + // ---- Manager-only entry points (@internal; never called by the UI) ---- + + /** + * Running-bit relay from the host stream (list entry and snapshot stay consistent). + * @param running - the new running state. + */ + handleRunning(running: boolean): void { + // Running converts display state without establishing durable turn history. + if (running && this.blankBit) { + this.blankBit = false; + this.notifier.markDirty(); + } + if (running) this.firstPromptPendingTurn = false; + if (this.running === running) return; + this.running = running; + this.notifier.markDirty(); + } + + /** + * Install or clear the catalog-discovered transport address. A changed + * address rebuilds an already-open window through its new history route. + * @param address - direct parent/child address, or undefined for ordinary transport. + * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. + */ + configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + const same = + this.address?.parentSessionId === address?.parentSessionId && + this.address?.childSessionId === address?.childSessionId && + this.address?.mode === address?.mode; + this.address = address; + this.parentAvailable = parentAvailable; + if (!same && this.openState !== "cold") void this.resync(); + else this.notifier.markDirty(); + } + + /** + * Update only the parent availability hint from a catalog refresh. + * @param available - whether the exact direct parent is live. + */ + handleSubagentParentAvailable(available: boolean): void { + if (this.parentAvailable === available) return; + this.parentAvailable = available; + this.notifier.markDirty(); + } + + /** + * Apply the Manager's effective display blank, further reconciled with the + * current `sessionListMetadata` projection. Local send attempts and current + * running state prevent re-blanking; an earlier false summary alone does not. + * The Manager retains acceptance and earlier running observations across + * Session-object replacement. + * @param blank - New Session display state after Manager reconciliation. + */ + handleBlank(blank: boolean): void { + blank = blank && this.projections.values().sessionListMetadata?.blank !== false; + if (blank === this.blankBit) return; + if (blank && (this.promptAttempted || this.running)) return; + this.blankBit = blank; + this.notifier.markDirty(); + } + + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ + handleRemoved(): void { + this.removed = true; + this.notifier.markDirty(); + } + + /** + * `api-session/error` relay: the outlet for live failures with no turn position. + * @param message - the stringified error. + */ + handleAgentError(message: string): void { + this.lastAgentError = message; + this.notifier.markDirty(); + } + + /** + * Stop the Session's live Remote source. + * @returns when the Remote iterator has completed teardown. + */ + async dispose(): Promise { + this.stopObservingInbox(); + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; admitted echoes keep their observed outcome. + for (const [requestId, settlement] of [...this.submissionSettlements]) { + if (settlement.admitted !== undefined) + this.scheduleObservedRetirement(requestId, settlement.admitted); + else this.retireFailedSubmission(requestId); + } + this.openGeneration++; + const events = this.events; + this.events = undefined; + await events?.dispose(); + } + + // ---- Private ---- + + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ + private async doOpen(generation: number): Promise { + this.openState = "loading"; + this.openError = null; + this.notifier.markDirty(); + const events = new SessionEventStream(this.remote, this.sessionAddress(), { + publish: (change) => { + if (generation !== this.openGeneration || this.events !== events) return; + this.acceptEventChange(change); + }, + failed: (error) => { + this.failEventStream(events, generation, error); + }, + }); + this.events = events; + try { + await events.open(HISTORY_PAGE_OPTIONS); + if (generation !== this.openGeneration || this.events !== events) return; + this.openState = "open"; + } catch (error) { + if (generation !== this.openGeneration || this.events !== events) return; + if (!isRemoteFailure(error)) throw error; + this.events = undefined; + this.openState = "error"; + this.openError = error; + } finally { + if (generation === this.openGeneration) this.notifier.markDirty(); + } + } + + /** Apply one contiguous journal update already reconciled by the Remote stream. */ + private acceptEventChange(change: SessionJournalChange): void { + switch (change.type) { + case "replace": + this.installWindow( + change.entries, + change.hasMore, + change.page.projections === undefined + ? undefined + : projectionsBaseline(change.page.projections), + change.page.assistantStream, + ); + return; + case "prepend": + this.prependWindow(change.entries, change.hasMore); + return; + case "append": + this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry)); + return; + case "assistant-stream": + this.publishAssistantEntry(this.assistantStream.acceptFrame(change.frame)); + } + } + + /** Replace the complete contiguous window and apply page-owned projection metadata. */ + private installWindow( + entries: readonly SessionEventLikeEntry[], + hasMore: boolean, + projections?: ProjectionsBaseline, + assistantStream?: SessionAssistantStreamBaseline, + ): void { + // A durable gap-repair page has no assistant baseline. Clearing transient + // attempts makes a held notification reopen follow once for an atomic + // page/baseline pair instead of applying it to an unrelated repair cut. + const visible = this.assistantStream.replace(entries, assistantStream); + this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0); + this.hasMore = hasMore; + if (this.pendingHistory !== null) { + this.pendingHistory.beforeSeq = this.baseSeq; + this.pendingHistory.hasMore = hasMore; + this.pendingHistory.pages.length = 0; + } + if (visible.some((entry) => entry.event.type === "turn/start")) + this.firstPromptPendingTurn = false; + if (projections !== undefined) this.projections.seed(projections); + this.eventSource.replace(visible, hasMore); + // A new follow baseline replaces confirmed optimistic inputs with Host-owned rows. + // Receipt-backed inputs are accepted, not failed, even if their history is outside this window. + if (projections !== undefined) { + for (const [requestId, { receipt }] of this.submissionSettlements) { + if (receipt !== undefined && receipt.seq <= projections.asOfSeq) { + this.scheduleObservedRetirement(requestId, receipt.attachments); + } + } + } + for (const entry of visible) this.observeSubmissionEvent(entry.event); + if (projections !== undefined) { + const inbox = projections.values.inbox as InboxState | undefined; + for (const target of ["next-turn", "next-step"] as const) { + this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq); + } + } + this.notifier.markDirty(); + } + + private publishAssistantEntry(result: ClientAssistantStreamResult): void { + if (result?.type === "rebaseline") { + const events = this.events; + queueMicrotask(() => { + if (events !== undefined && this.events === events) events.restart(); + }); + return; + } + if (result?.type === "settlement") { + this.eventSource.settleAssistant(result.attemptId, result.entry); + this.observeSubmissionEvent(result.entry.event); + this.notifier.markDirty(); + return; + } + if (result?.type === "abandonment") { + this.eventSource.settleAssistant(result.attemptId); + this.notifier.markDirty(); + return; + } + if (result?.type === "publish") { + const changed = this.appendLive(result.entry); + if (result.retireAttemptId !== undefined) + this.eventSource.settleAssistant(result.retireAttemptId); + if (changed || result.retireAttemptId !== undefined) this.notifier.markDirty(); + } else if (result?.type === "transient") { + this.eventSource.append(result.entry); + this.notifier.markDirty(); + } + } + + /** Prepend one stream-validated history page. */ + private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + if (this.pendingHistory !== null) { + const pending = this.pendingHistory; + pending.beforeSeq = + entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq); + pending.hasMore = hasMore; + pending.pages.push(entries); + return; + } + this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq); + this.hasMore = hasMore; + this.eventSource.prepend(entries, hasMore); + } + + /** Append one stream-validated live event. */ + private appendLive(entry: SessionLiveEventEntry): boolean { + const event = entry.event; + const awaitingFirstTurn = this.firstPromptPendingTurn; + if (event.type === "turn/start") this.firstPromptPendingTurn = false; + this.eventSource.append(entry); + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event); + return awaitingFirstTurn !== this.firstPromptPendingTurn; + } + + /** Observe durable acceptance even when insertion and claim share one projection notification. */ + private observeSubmissionEvent(event: SessionEventLike): void { + if (this.submissionSettlements.size === 0) return; + if (event.type === "agent/inbox/spliced") { + const { target, start, removedCount = 0, inserted, outcome } = event.data; + for (const [requestId, settlement] of this.submissionSettlements) { + const receipt = settlement.receipt; + if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) + continue; + const removed = receipt.index >= start && receipt.index < start + removedCount; + if (removed && outcome === "canceled") this.retireFailedSubmission(requestId); + else + settlement.receipt = { + ...receipt, + seq: event.seq, + index: removed + ? null + : receipt.index < start + ? receipt.index + : receipt.index + inserted.length - removedCount, + }; + } + this.observeSubmissionInsertions(target, inserted, start, event.seq); + for (const message of inserted) this.observeSubmissionMessage(message, false); + return; + } + if (event.type === "request/context" || event.type === "turn/end") { + for (const [requestId, settlement] of this.submissionSettlements) { + if ( + settlement.admitted === undefined && + settlement.receipt?.index === null && + settlement.receipt.seq < event.seq + ) + this.retireFailedSubmission(requestId); + } + return; + } + if (event.type === "user/message") this.observeSubmissionMessage(event.data, true); + } + + private observeSubmissionInsertions( + target: InboxTarget, + messages: readonly UserMessage[], + start: number, + seq: number, + ): void { + for (const [index, message] of messages.entries()) { + const source = message.source; + if (source.kind !== "user" || !("rpcId" in source)) continue; + const settlement = this.submissionSettlements.get(source.rpcId); + if ( + settlement === undefined || + settlement.placement === "queued" || + settlement.retiring || + (settlement.receipt?.seq ?? -1) > seq + ) + continue; + settlement.receipt = { + target, + seq, + index: start + index, + attachments: attachmentRefsIn(message.content), + }; + } + } + + private observeSubmissionMessage(message: UserMessage, admitted: boolean): void { + const source = message.source; + if (source.kind !== "user" || !("rpcId" in source)) return; + const settlement = this.submissionSettlements.get(source.rpcId); + if (settlement === undefined || settlement.retiring) return; + if (!admitted) { + if (settlement.placement === "queued") + this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content)); + return; + } + settlement.admitted = attachmentRefsIn(message.content); + this.retireAdmittedSubmission(source.rpcId); + } + + /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */ + private retireAdmittedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement?.admitted === undefined) return; + const receipt = settlement.receipt; + if (receipt?.index === null && (this.projections.seqOf("inbox") ?? -1) < receipt.seq) return; + this.scheduleObservedRetirement(requestId, settlement.admitted); + } + + /** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */ + private observeSubmissionInbox(): void { + if (this.submissionSettlements.size === 0) return; + const inbox = this.projections.get("inbox") as InboxState | undefined; + if (inbox === undefined) return; + const seq = this.projections.seqOf("inbox"); + for (const target of ["next-turn", "next-step"] as const) { + if (seq !== undefined) this.observeSubmissionInsertions(target, inbox[target], 0, seq); + for (const message of inbox[target]) this.observeSubmissionMessage(message, false); + } + for (const requestId of this.submissionSettlements.keys()) + this.retireAdmittedSubmission(requestId); + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], + ): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement === undefined || settlement.retiring) return; + settlement.retiring = true; + scheduleFrame(() => { + this.finishSubmission(requestId, { reason: "observed", attachments }); + }); + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId); + if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined) + return; + settlement.retiring = true; + this.finishSubmission(requestId, { reason: "failed" }); + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission( + requestId: SessionRequestId, + retirement: PendingSubmissionRetirement, + ): void { + const settlement = this.submissionSettlements.get(requestId); + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return; + this.submissionSettlements.delete(requestId); + this.pendingSubmissions = this.pendingSubmissions.filter( + (echo) => echo.requestId !== requestId, + ); + this.notifier.markDirty(); + settlement.onRetire?.(retirement); + } + + /** Publish a terminal background failure only while this stream still owns the Session. */ + private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { + if (generation !== this.openGeneration || this.events !== events) return; + if (!isRemoteFailure(error)) throw error; + this.openGeneration++; + this.events = undefined; + this.openPromise = null; + this.openState = "error"; + this.openError = error; + void events.dispose(); + this.notifier.markDirty(); + } + + private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent; + return { + sessionId: this.sessionId, + pendingSubmissions: this.pendingSubmissions, + running: this.running, + subagent: + this.address === undefined + ? null + : { + address: + this.address.mode === "unknown" && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, + ...(this.parentAvailable === undefined + ? {} + : { parentAvailable: this.parentAvailable }), + }, + removed: this.removed, + openState: this.openState, + openError: this.openError, + hasMore: this.hasMore, + loadingOlder: this.loadingOlder, + promptError: this.promptError, + blank: this.blankBit, + lastAgentError: this.lastAgentError, + promptAttempted: this.promptAttempted, + awaitingFirstTurn: this.firstPromptPendingTurn, + }; + } + + private sessionAddress(): SessionAddress { + return this.address === undefined + ? { kind: "session", sessionId: this.sessionId } + : { kind: "subagent", ...this.address }; + } +} + +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === "function") + requestAnimationFrame(() => { + fn(); + }); + else setTimeout(fn, 0); +} + +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { + if (!Array.isArray(content)) return []; + const refs: Array = []; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }; + if ( + (candidate.type === "image" || candidate.type === "file") && + typeof candidate.attachment === "object" && + candidate.attachment !== null + ) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef); + } + } + return refs; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts b/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts new file mode 100644 index 000000000..53a5d0d68 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/time-zone.ts @@ -0,0 +1,14 @@ +/** Browser-owned time-zone sampling for one prompt RPC. */ + +/** + * Resolve the current browser IANA zone for one outbound operation. + * @returns The browser-provided canonical zone. + * @throws when the runtime cannot provide a non-empty zone. + */ +export function resolvedClientTimeZone(): string { + const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + if (typeof timeZone !== "string" || timeZone.length === 0) { + throw new Error("browser time zone is unavailable"); + } + return timeZone; +} diff --git a/apps/web-ui/packages/api/session-controller/src/client/transport.ts b/apps/web-ui/packages/api/session-controller/src/client/transport.ts new file mode 100644 index 000000000..1ca6abcbc --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/client/transport.ts @@ -0,0 +1,252 @@ +/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ + +import type {} from "@deepseek-ai/dsh-api-session-controller/remote"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { + RemoteJournalStream, + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, + type RemoteJournalChange, + type RemoteJournalFrame, +} from "@deepseek-ai/dsh-api-gateway/client"; +import type { + SessionAddress, + SessionAssistantStreamBaseline, + SessionAssistantStreamFrame, + SessionControlFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, +} from "../types.ts"; +import { + historyEntries, + historyRecordFirstSeq, + historyRecordLastSeq, +} from "./sessions/history-records.ts"; +import type { SessionEventLikeEntry, SessionLiveEventEntry } from "./contract/events.ts"; +import type { SessionRemotes } from "./sessions/remotes.ts"; +import { assertSessionWireEvent } from "./session-wire-event.ts"; + +export { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS } from "../types.ts"; + +/** Pagination fields bound to an already-addressed Session journal. */ +export type ClientSessionPageRequest = Omit; + +/** Complete generated `ctx.remote.session` namespace. */ +export type SessionRemote = ClientRemote["session"]; + +/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */ +interface SessionJournalPage extends SessionPage { + readonly projections?: SessionProjectionBaseline; + readonly assistantStream?: SessionAssistantStreamBaseline; +} + +/** One complete publication from the Session journal stream. */ +export type SessionJournalChange = + | { + readonly type: "replace" | "prepend"; + readonly page: SessionJournalPage; + readonly entries: readonly SessionEventLikeEntry[]; + readonly hasMore: boolean; + } + | { readonly type: "append"; readonly entry: SessionLiveEventEntry } + | { readonly type: "assistant-stream"; readonly frame: SessionAssistantStreamFrame }; + +function toSessionJournalChange( + change: RemoteJournalChange< + SessionJournalPage, + SessionHistoryRecord, + SessionAssistantStreamFrame + >, +): SessionJournalChange { + switch (change.type) { + case "replace": + case "prepend": + return { ...change, entries: historyEntries(change.entries) }; + case "append": { + return { + type: "append", + entry: change.entry as unknown as SessionLiveEventEntry, + }; + } + case "notification": + return { type: "assistant-stream", frame: change.notification }; + } +} + +type SessionControlBaselineFrame = Extract; +type SessionControlDeltaFrame = Exclude; + +/** Gateway-owned control snapshot stream configured for Session frames. */ +export type SessionControlStream = RemoteSnapshotStream< + SessionControlBaselineFrame, + SessionControlDeltaFrame +>; + +/** Domain sinks used by the Host-wide Session control stream. */ +export interface SessionControlStreamOptions { + /** Apply a complete baseline or one later update. */ + readonly accept: (frame: SessionControlFrame) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** Domain sinks used by one addressed Session event journal. */ +export interface SessionEventStreamOptions { + /** Apply one complete event-window change. */ + readonly publish: (change: SessionJournalChange) => void; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void; +} + +/** + * Create the Host-wide Session control snapshot stream. + * @param remote - generated Session namespace and Gateway stream factory. + * @param options - Session state destinations. + * @returns an unstarted stream owned by the Client Session runtime. + */ +export function createSessionControlStream( + remote: SessionRemotes, + options: SessionControlStreamOptions, +): SessionControlStream { + const stream = remote.$stream({ + name: "session control stream", + open: (signal) => remote.session.control(signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError("session control stream ended without a terminal result") + : new Error("session control stream ended before its opening snapshot"), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + return new RemoteSnapshotStream(stream, { + name: "session control stream", + isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === "baseline", + replace: options.accept, + update: options.accept, + failed: options.failed, + }); +} + +/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */ +export class SessionEventStream extends RemoteJournalStream< + SessionJournalPage, + SessionHistoryRecord, + number, + ClientSessionPageRequest, + SessionAssistantStreamFrame +> { + /** + * @param remote - generated Session namespace and Gateway stream factory. + * @param address - durable ordinary-Session or direct-subagent address. + * @param options - Session event-window destinations. + */ + constructor( + private readonly remote: SessionRemotes, + private readonly address: SessionAddress, + options: SessionEventStreamOptions, + ) { + super(remote, { + name: "session event stream", + emptyCursor: -1, + entries: (page) => page.records, + hasMore: (page) => page.hasMore, + first: historyRecordFirstSeq, + last: historyRecordLastSeq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { + options.publish(toSessionJournalChange(change)); + }, + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + failed: options.failed, + }); + } + + /** @inheritdoc */ + protected override async *follow( + request: ClientSessionPageRequest, + signal: AbortSignal, + ): AsyncIterable< + RemoteJournalFrame< + SessionHistoryRecord, + number, + SessionJournalPage, + SessionAssistantStreamFrame + > + > { + let assistantRevision: number | undefined; + for await (const frame of this.remote.session.follow( + { + address: this.address, + assistantStream: true, + ...this.repairRequest(request), + }, + signal, + )) { + if (frame.type === "snapshot") { + for (const record of frame.records) assertSessionWireEvent(record.event); + if (frame.assistantStream === undefined) { + throw new RemoteError( + "gateway/internal", + "session assistant stream omitted its opted-in opening baseline", + {}, + ); + } + assistantRevision = frame.assistantStream.revision; + yield { + type: "opened", + cursor: frame.cursor, + page: { + records: frame.records, + hasMore: frame.hasMore, + projections: frame.projections, + assistantStream: frame.assistantStream, + }, + }; + continue; + } + if (frame.type === "assistant-stream") { + const expected = (assistantRevision ?? 0) + 1; + if (frame.frame.revision !== expected) { + throw new RemoteStreamCarrierError( + `session assistant stream skipped revision ${String(expected)}`, + ); + } + assistantRevision = frame.frame.revision; + yield { type: "notification", notification: frame.frame }; + continue; + } + assertSessionWireEvent(frame.event); + yield { type: "entry", entry: frame }; + } + } + + /** @inheritdoc */ + protected override async readPage( + request: ClientSessionPageRequest, + throughSeq: number, + signal: AbortSignal, + ): Promise { + const result = await this.remote.session.page( + { address: this.address, throughSeq, ...request }, + signal, + ); + if (!result.ok) throw result.error; + for (const record of result.value.records) assertSessionWireEvent(record.event); + return result.value; + } + + /** @inheritdoc */ + protected override repairRequest(request: ClientSessionPageRequest): ClientSessionPageRequest { + return { + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + ...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }), + }; + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/commands.ts b/apps/web-ui/packages/api/session-controller/src/commands.ts new file mode 100644 index 000000000..674a26e36 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/commands.ts @@ -0,0 +1,751 @@ +/** Session commands whose activation policy is explicit at each Remote method. */ + +import { modelAvailable } from "./catalog.ts"; +import { randomUUID } from "node:crypto"; +import type { Context } from "@deepseek-ai/cordis"; +import { brandString } from "@deepseek-ai/dsh-brand"; +import type { Agent, ModelSelection as AgentModelSelection } from "@deepseek-ai/dsh-agent"; +import { AttachmentError } from "@deepseek-ai/dsh-attachment"; +import type { + AttachmentAdmissionPart, + FileAttachmentRef, + ImageAttachmentRef, +} from "@deepseek-ai/dsh-attachment"; +import type { FileUploadReceiptId } from "@deepseek-ai/dsh-client-file-upload/types"; +import type {} from "@deepseek-ai/dsh-client-file-upload"; +import { + ReasoningEffortId, + assistantStreamChunks, + createUserMessage, + freezeMessage, +} from "@deepseek-ai/dsh-llm"; +import type { MessageSource } from "@deepseek-ai/dsh-llm"; +import { buildForkSeed } from "@deepseek-ai/dsh-session/fork"; +import { SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session"; +import type { SessionEvent, SessionHeader, SessionId, UserMessage } from "@deepseek-ai/dsh-session"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { SessionTitleInvalidError } from "@deepseek-ai/dsh-session-title"; +import { canonicalClientTimeZone } from "@deepseek-ai/dsh-util-time"; +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { Workspace } from "@deepseek-ai/dsh-workspace"; +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionPresetConflict, + ApiSessionSubagentOwnership, + apiSessionSubagentOwnershipError, + hasApiSessionSubagentOwner, + inspectApiSession, +} from "./agent.ts"; +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, + SessionRequestId, +} from "./types.ts"; + +interface SessionReadState { + readonly id: SessionId; + readonly header: SessionHeader; + readonly events: readonly SessionEvent[]; +} + +type PromptContentCandidate = + | SessionPromptRequest["content"][number] + | Extract["content"][number]; + +function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { + return content.some((part) => part.type !== "text" || part.text.trim().length > 0); +} + +/** + * Resolve the omitted-`atSeq` default to the latest completed-turn prefix, + * including standalone events before the next turn begins. + */ +function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): SessionSeq | undefined { + const lastTurnEnd = events.findLast((event) => event.type === "turn/end"); + if (lastTurnEnd === undefined) return undefined; + let boundary = lastTurnEnd.seq; + for (const next of events.slice(boundary + 1)) { + if ( + next.type === "turn/start" || + (next.type === "user/message" && next.surfaceOp === "append") || + next.type === "agent/inbox/spliced" + ) + break; + boundary = next.seq; + } + return boundary; +} + +/** Implements Session business commands delegated by the Session Controller Remote service. */ +export class SessionCommandController { + /** + * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. + * @param agents - sole owner of create, resume, and Session-local model selection. + * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. + */ + constructor( + private readonly ctx: Context, + private readonly agents: ApiSessionAgentController, + private readonly defaultCwd: string, + ) {} + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + async create(request: SessionCreateRequest): Promise { + if (request.workspaceId !== undefined && request.cwd !== undefined) { + throw new RemoteError( + "gateway/bad-request", + "session.create accepts workspaceId or cwd, not both", + {}, + ); + } + const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`); + let workspace: Workspace | undefined; + if (request.workspaceId !== undefined) { + workspace = this.ctx.workspaceRegistry.get(request.workspaceId); + if (workspace === undefined) { + throw new RemoteError( + "workspace/not-found", + `workspace "${request.workspaceId}" not found`, + { + workspaceId: request.workspaceId, + }, + ); + } + } + const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd; + let adopted: Agent; + try { + adopted = await this.agents.ensureSession( + sessionId, + cwd, + request.sessionId !== undefined, + request.agentPreset, + ); + } catch (error) { + this.rejectCreation(sessionId, error); + } + if (workspace !== undefined) { + try { + await workspace.attachSession(sessionId); + } catch (error) { + throw new RemoteError( + "session/workspace-attach-failed", + `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId, workspaceId: workspace.id }, + ); + } + } + const agentPreset = this.agents.presetForSession(adopted.session); + return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }; + } + + /** + * Validate and install one Session-local model selection; save the default in the background. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session, without waiting for default persistence. + */ + async selectModel(request: SessionSelectModelRequest): Promise { + const agent = await this.resolveAgent(request.sessionId); + return this.agents.serializeImageAdmission(agent, async () => { + try { + await this.requireModel(request); + const resolved = await this.ctx.llm.resolveCallConfig({ + provider: request.provider, + model: request.model, + ...(request.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), + }); + const selected: AgentModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...(resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }), + }; + this.agents.selectForNextRequest(agent, selected); + void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => { + this.ctx.logger.warn( + `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, + ); + }); + return { selected: { ...selected } }; + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + throw new RemoteError( + "session/model-unavailable", + error instanceof Error ? error.message : String(error), + { provider: request.provider, model: request.model }, + ); + } + }); + } + + /** + * Normalize and append a user-owned Session title. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + async rename(request: SessionRenameRequest): Promise { + const agent = await this.resolveAgent(request.sessionId); + const titles = this.ctx.get("sessionTitle"); + if (titles === undefined) { + throw new RemoteError( + "gateway/internal", + "renaming is unavailable: this deployment mounts no session-title service", + {}, + ); + } + try { + const accepted = titles.rename(agent.session, request.title); + return { title: accepted.title, seq: accepted.eventSeq }; + } catch (error) { + if (error instanceof SessionTitleInvalidError) { + throw new RemoteError("session/title-invalid", error.message, { + sessionId: request.sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `failed to rename session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + } + + /** + * Create a new ordinary Session from an exact event prefix. An explicit + * `atSeq` is the inclusive cut; an omitted value selects the latest + * completed-turn prefix. An open cut receives synthetic fork closers. + * @param request - source Session and optional exact event boundary. + * @returns the new Session identity. + */ + async fork(request: SessionForkRequest): Promise { + let atSeq: ReturnType | undefined; + try { + atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq); + } catch { + throw new RemoteError("gateway/bad-request", "atSeq must be a non-negative safe integer", {}); + } + let observed: SessionObservation; + try { + observed = await this.ctx.sessionQuery.observeSession(request.sessionId); + } catch (error) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new RemoteError("session/not-found", `session "${request.sessionId}" not found`, { + sessionId: request.sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `fork source unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + using source = observed; + const boundary = atSeq ?? latestCompletedPrefixBoundary(source.events); + if (boundary === undefined || source.events[boundary]?.seq !== boundary) { + throw new RemoteError( + "session/fork-unavailable", + request.atSeq === undefined + ? `session "${request.sessionId}" has no completed turn to fork from` + : `event ${String(request.atSeq)} does not exist in session "${request.sessionId}" (last seq: ${String(source.events.at(-1)?.seq ?? "none")})`, + { sessionId: request.sessionId }, + ); + } + const seed = buildForkSeed(source.events, boundary); + let workspace: Workspace | undefined; + try { + workspace = await this.forkWorkspace(source.header); + } catch (error) { + throw new RemoteError( + "gateway/internal", + `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + const childId = brandString(`session-${randomUUID()}`); + const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)); + try { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection(); + await this.ctx.agents.create({ + sessionId: childId, + seed, + inheritedEventCount: SessionLogOffset(boundary + 1), + meta: { + ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), + parentSession: source.header.id, + isSeeded: true, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + agentOptions: { provider, model }, + setup: composition.setup, + }); + } catch (error) { + throw new RemoteError( + "gateway/internal", + `failed to fork session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + if (workspace !== undefined) { + try { + await workspace.attachSession(childId); + } catch (error) { + throw new RemoteError( + "session/workspace-attach-failed", + `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId: childId, workspaceId: workspace.id }, + ); + } + } + return { sessionId: childId }; + } + + /** + * Reject empty content, then admit one prompt after Agent and attachment validation. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @returns acknowledgement that the Agent accepted the prompt. + */ + async prompt(request: SessionPromptRequest): Promise { + if (!hasPromptContent(request.content)) { + throw new RemoteError( + "gateway/bad-request", + "prompt content must include non-whitespace text or an attachment", + {}, + ); + } + const clientTimeZone = + request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone); + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + "session/invalid-time-zone", + "clientTimeZone must be UTC or a valid IANA Area/Location name", + { value: request.clientTimeZone }, + ); + } + const agent = await this.resolveAgent(request.sessionId); + if (hasPromptRequest(agent, request.requestId)) return { accepted: true }; + const source: MessageSource = { + kind: "user", + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + }; + const hasImage = request.content.some((part) => part.type === "image"); + const admit = async (): Promise => { + try { + if (hasImage) { + const current = this.agents.selectionFor(agent).current; + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model); + if (model.inputModalities !== undefined && !model.inputModalities.includes("image")) { + throw new RemoteError( + "session/attachment-invalid", + `Model "${current.model}" does not support image input.`, + { reason: "MODEL_DOES_NOT_SUPPORT_IMAGES" }, + ); + } + } + const admission = resolvePromptFileReceipts(request.content, (receiptId) => + this.ctx.fileUploads.resolve(agent, receiptId), + ); + const content = await this.ctx.attachments.admitPromptContent(admission.content); + const message: UserMessage = createUserMessage({ content, source }); + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + "session/not-found", + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, + ); + } + using binding = this.ctx.fileUploads.bindPrompt( + agent, + admission.receiptIds, + request.requestId, + ); + if (request.mode === "steer") agent.steer(message); + else agent.followup(message); + binding.commit(); + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + if (error instanceof AttachmentError) { + throw new RemoteError("session/attachment-invalid", error.message, { + reason: error.code, + }); + } + throw new RemoteError("session/agent-busy", "prompt rejected", { reason: String(error) }); + } + return { accepted: true }; + }; + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit(); + } + + private async requireModel( + selection: Pick, + ): Promise { + if (!(await modelAvailable(this.ctx, selection))) { + throw new RemoteError( + "session/model-unavailable", + "Select an available model before sending a message.", + { provider: selection.provider, model: selection.model }, + ); + } + } + + /** + * Read one durable image after proving the Session log references it. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + async attachment(request: SessionAttachmentRequest): Promise { + let source: SessionReadState; + try { + source = await this.readSessionState(request.sessionId); + } catch (error) { + if (error instanceof ApiSessionNotFound) { + throw new RemoteError("session/not-found", error.message, { sessionId: request.sessionId }); + } + throw new RemoteError( + "gateway/internal", + `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ); + } + const ref = referencedImage(source.events, String(request.attachmentId)); + if (ref === undefined) { + throw new RemoteError( + "session/attachment-invalid", + "Image is not referenced by this session.", + { reason: "ATTACHMENT_NOT_REFERENCED" }, + ); + } + try { + const stored = await this.ctx.attachments.readImage(ref); + return { + attachment: stored.ref, + data: Buffer.from(stored.data).toString("base64"), + }; + } catch (error) { + if (error instanceof AttachmentError) { + throw new RemoteError("session/attachment-invalid", error.message, { reason: error.code }); + } + throw new RemoteError("gateway/internal", "Unable to read image attachment.", {}); + } + } + + /** + * Mutate one pending Inbox occurrence, restoring an ordinary cold Agent when needed. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + async updateQueue(request: SessionUpdateQueueRequest): Promise { + if (request.action.kind === "edit") { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote callers can submit untyped JSON. + if (request.action.content.some((block) => block.type !== "text")) { + throw new RemoteError( + "session/attachment-invalid", + "queue edits accept text content only", + { reason: "QUEUE_EDIT_NON_TEXT" }, + ); + } + if (!hasPromptContent(request.action.content)) { + throw new RemoteError( + "gateway/bad-request", + "queue edit content must include non-whitespace text", + {}, + ); + } + } + let agent = this.ctx.agents.get(request.sessionId); + if (agent === undefined) { + const found = await this.agents.resolveAgent(request.sessionId); + if ("error" in found) { + if (found.error.code !== "session/not-found") throw found.error; + throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { + itemId: request.itemId, + }); + } + agent = found.agent; + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + const identity = this.ctx.sessionProjections.snapshot(agent.session, ["subagent"]).values + .subagent; + if (identity?.mode !== "continuable" || !agent.session.isOwnSeq(identity.seq)) { + throw apiSessionSubagentOwnershipError(request.sessionId); + } + } + const nextTurn = agent.inbox.nextTurn.find((message) => message.id === request.itemId); + const nextStep = agent.inbox.nextStep.find((message) => message.id === request.itemId); + const located = + nextTurn === undefined + ? nextStep === undefined + ? undefined + : { target: "next-step" as const, message: nextStep } + : { target: "next-turn" as const, message: nextTurn }; + if (located === undefined) { + throw new RemoteError("session/queue-item-not-found", "queued item is no longer pending", { + itemId: request.itemId, + }); + } + const { target, message } = located; + if (request.action.kind === "steer" && (target !== "next-turn" || agent.status !== "running")) { + throw new RemoteError( + "session/steer-unavailable", + "current turn no longer accepts steering", + { itemId: request.itemId }, + ); + } + switch (request.action.kind) { + case "edit": + agent.inbox.replace( + request.itemId, + freezeMessage({ + ...message, + content: [...request.action.content], + }), + ); + break; + case "remove": { + agent.inbox.remove(request.itemId); + const source = message.source; + if (source.kind === "user" && "rpcId" in source) { + this.ctx.fileUploads.retirePrompt(agent, source.rpcId); + } + break; + } + case "steer": + agent.inbox.remove(request.itemId); + agent.steer(message); + break; + /* v8 ignore next 2 -- closed-union exhaustiveness guard */ + default: + assertNever(request.action, "queue action"); + } + return { accepted: true }; + } + + /** + * Cancel one live ordinary Agent while retaining pending inbox work. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + cancel(request: SessionCancelRequest): SessionCancelValue { + const agent = this.ctx.agents.get(request.sessionId); + if (agent === undefined) { + throw new RemoteError( + "session/not-found", + `session "${request.sessionId}" not found (not attached)`, + { sessionId: request.sessionId }, + ); + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw apiSessionSubagentOwnershipError(request.sessionId); + } + agent.cancel({ kind: "user" }, { keepInbox: true }); + return { accepted: true }; + } + + private async resolveAgent(sessionId: SessionId): Promise { + const found = await this.agents.resolveAgent(sessionId); + if ("error" in found) throw found.error; + return found.agent; + } + + private rejectCreation(sessionId: SessionId, error: unknown): never { + if (remoteErrorOf(error) !== undefined) throw error; + if (error instanceof Error && error.name === "SessionAlreadyOwnedError") { + throw new RemoteError("session/writer-held", error.message, { sessionId }); + } + if (error instanceof ApiSessionPresetConflict) { + throw new RemoteError("agent-preset/conflict", error.message, { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), + }); + } + if (error instanceof ApiSessionCwdConflict) { + throw new RemoteError("session/conflict", error.message, { + sessionId: error.sessionId, + requestedCwd: error.requestedCwd, + ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), + }); + } + if (error instanceof ApiSessionSubagentOwnership) { + throw apiSessionSubagentOwnershipError(error.sessionId); + } + throw new RemoteError( + "gateway/internal", + `failed to create session "${sessionId}": ${String(error)}`, + {}, + ); + } + + private async readSessionState(sessionId: SessionId): Promise { + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }; + } + const inspected = await inspectApiSession(this.ctx, sessionId); + return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }; + } + + private async forkWorkspace(source: SessionHeader): Promise { + const workspaces = this.ctx.workspaceRegistry.list(); + const direct = workspaces.find((workspace) => workspace.sessionIds.includes(source.id)); + if (direct !== undefined || source.origin !== "subagent") return direct; + const lineage = await this.ctx.sessionQuery.traceSession(source.id); + for (const ancestor of lineage.ancestors) { + const workspace = workspaces.find((candidate) => + candidate.sessionIds.includes(ancestor.header.id), + ); + if (workspace !== undefined) return workspace; + } + return undefined; + } +} + +function resolvePromptFileReceipts( + content: SessionPromptRequest["content"], + stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, +): { + readonly content: AttachmentAdmissionPart[]; + readonly receiptIds: readonly FileUploadReceiptId[]; +} { + const receiptIds = new Set(); + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== "file") return part; + const attachment = stagedFile(part.receiptId); + if (attachment === undefined) { + throw new RemoteError( + "session/attachment-invalid", + "File was not uploaded for this session.", + { reason: "FILE_NOT_STAGED" }, + ); + } + receiptIds.add(part.receiptId); + return { type: "file", attachment }; + }); + return { content: resolved, receiptIds: [...receiptIds] }; +} + +function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { + const matches = (message: UserMessage): boolean => { + const source = message.source; + return source.kind === "user" && "rpcId" in source && source.rpcId === requestId; + }; + if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true; + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + return agent.session.snapshotEvents().some((event) => { + if (event.type !== "user/message") return false; + const source = event.data.source; + return source.kind === "user" && "rpcId" in source && source.rpcId === requestId; + }); +} +function imageBlockIn( + content: unknown, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined; + for (const value of content) { + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + const block = value as { readonly type?: unknown; readonly attachment?: unknown }; + if ( + block.type === "image" && + typeof block.attachment === "object" && + block.attachment !== null + ) { + const ref = block.attachment as ImageAttachmentRef; + if (match(ref)) return ref; + } + } + return undefined; +} + +/** Read only first-party declared content fields; unknown event payloads stay opaque. */ +function imageInEvent( + event: SessionEvent, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + const data = event.data as { + readonly content?: unknown; + readonly message?: { readonly content?: unknown }; + readonly inserted?: unknown; + readonly summary?: unknown; + readonly rawOutput?: unknown; + }; + // First-party event payloads can be present without their producer plugin mounted. + const type: string = event.type; + switch (type) { + case "user/message": + case "tool/ptc-dispatch": + return imageBlockIn(data.content, match); + case "system/message": + case "developer/message": + case "tool/result": + case "team/message/queued": + return imageBlockIn(data.message?.content, match); + case "agent/inbox/spliced": { + const messages = data.inserted; + if (!Array.isArray(messages)) return undefined; + for (const message of messages as readonly unknown[]) { + if (typeof message !== "object" || message === null || Array.isArray(message)) continue; + const found = imageBlockIn((message as { readonly content?: unknown }).content, match); + if (found !== undefined) return found; + } + return undefined; + } + case "compaction/summary": + return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match); + case "assistant/message": { + const found = imageBlockIn(data.message?.content, match); + if (found !== undefined) return found; + break; + } + case "assistant/attempt": + break; + default: + return undefined; + } + const assistant = event as SessionEvent<"assistant/message" | "assistant/attempt">; + for (const chunk of assistantStreamChunks(assistant.data.stream, "block-end")) { + const found = imageBlockIn([chunk.block], match); + if (found !== undefined) return found; + } + return undefined; +} + +function referencedImage( + events: readonly SessionEvent[], + attachmentId: string, +): ImageAttachmentRef | undefined { + for (const event of events) { + const found = imageInEvent(event, (ref) => String(ref.attachmentId) === attachmentId); + if (found !== undefined) return found; + } + return undefined; +} diff --git a/apps/web-ui/packages/api/session-controller/src/control.ts b/apps/web-ui/packages/api/session-controller/src/control.ts new file mode 100644 index 000000000..e7de7e643 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/control.ts @@ -0,0 +1,127 @@ +/** Live Session projection state with reconnect baselines. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { Session, SessionId } from "@deepseek-ai/dsh-session"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { + SessionControlBaseline, + SessionControlFrame, + SessionProjectionBaseline, + SessionProjectionValues, +} from "./types.ts"; + +/** Owns the Host-wide Session control stream. */ +export class SessionControlController { + private readonly streams = new Set(); + + /** @param ctx - Host context carrying live Agent and projection services. */ + constructor(private readonly ctx: Context) { + ctx.sessionProjections.onChanged((session, key, value, seq) => { + this.broadcast({ + type: "projection", + sessionId: session.id, + key, + value: value as JsonValue, + seq, + }); + }); + ctx.effect( + () => () => { + for (const stream of this.streams) stream.end(); + this.streams.clear(); + }, + "session-controller.control", + ); + } + + /** + * Open one generation of Host-wide live control state. + * @param signal - Remote stream cancellation. + * @returns one complete baseline followed by live replacement frames. + */ + async *control(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const queue = new ControlQueue(); + this.streams.add(queue); + try { + yield { type: "baseline", value: this.baseline() }; + yield* queue.iterate(signal); + } finally { + this.streams.delete(queue); + queue.end(); + } + } + + private baseline(): SessionControlBaseline { + const sessions = this.ctx.sessions.list(); + return { + projections: this.projectionBaseline(sessions), + }; + } + + private projectionBaseline( + sessions: readonly Session[], + ): Readonly> { + const blocks = Object.create(null) as Record; + for (const session of sessions) { + const snapshot = this.ctx.sessionProjections.snapshot(session); + blocks[session.id] = { + asOfSeq: snapshot.asOfSeq, + // Every projection definition validates its value before snapshot publication. + values: snapshot.values as SessionProjectionValues, + }; + } + return blocks; + } + + private broadcast(frame: SessionControlFrame): void { + for (const stream of this.streams) stream.push(frame); + } +} + +class ControlQueue { + private readonly buffer = new Deque(); + private wake: (() => void) | undefined; + private done = false; + + push(frame: SessionControlFrame): void { + if (this.done) return; + this.buffer.pushBack(frame); + const wake = this.wake; + this.wake = undefined; + wake?.(); + } + + end(): void { + if (this.done) return; + this.done = true; + const wake = this.wake; + this.wake = undefined; + wake?.(); + } + + async *iterate(signal: AbortSignal): AsyncIterable { + const onAbort = (): void => { + this.end(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + while (!this.done && !signal.aborted) { + const frame = this.buffer.popFront(); + if (frame !== undefined) { + yield frame; + continue; + } + await new Promise((resolve) => { + this.wake = resolve; + }); + } + while (this.buffer.size > 0 && !signal.aborted) + yield this.buffer.popFront() as SessionControlFrame; + } finally { + signal.removeEventListener("abort", onAbort); + this.end(); + } + } +} diff --git a/apps/web-ui/packages/api/session-controller/src/file-references.ts b/apps/web-ui/packages/api/session-controller/src/file-references.ts new file mode 100644 index 000000000..2362d657b --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/file-references.ts @@ -0,0 +1,38 @@ +/** Session Controller adapter for Agent-scoped file-reference discovery. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type {} from "@deepseek-ai/dsh-file-reference"; +import type { FileReferenceCandidate } from "@deepseek-ai/dsh-file-reference/types"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `fileReferences` Remote namespace. */ + sessionFileReferences: SessionFileReferences; + } +} + +/** Host Remote adapter over the composed file-reference provider. */ +export class SessionFileReferences extends TypertRemoteService { + static inject = ["fileReferences", "typert"]; + + /** @param ctx - Host context carrying the selected file-reference provider. */ + constructor(ctx: Context) { + super(ctx, "sessionFileReferences", { namespace: "fileReferences" }); + } + + /** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ + @Remote + list(agent: Agent, query: string, signal: AbortSignal): Promise { + return this.ctx.fileReferences.list(agent, query, signal); + } +} + +export default SessionFileReferences; diff --git a/apps/web-ui/packages/api/session-controller/src/history.ts b/apps/web-ui/packages/api/session-controller/src/history.ts new file mode 100644 index 000000000..0cf3d1998 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/history.ts @@ -0,0 +1,528 @@ +/** Cold Session history pagination and live-event source. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { AssistantStreamFrame } from "@deepseek-ai/dsh-agent"; +import { isAppendSurfaceEvent, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session"; +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionLogOffset as SessionLogOffsetType, + SessionSeqCursor, +} from "@deepseek-ai/dsh-session"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import type {} from "@deepseek-ai/dsh-subagent"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { + SessionAddress, + SessionAssistantStreamFrame, + SessionEventEntry, + SessionFollowRequest, + SessionFollowFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, + SessionProjectionValues, + SessionWireHeader, + SessionWireEvent, +} from "./types.ts"; +import { SessionAssistantStreamAccumulator } from "./assistant-stream.ts"; + +const DEFAULT_MAX_MESSAGES = 50; +const MESSAGE_TYPES = new Set(["user/message", "assistant/message"]); + +/** Implements cold-safe history operations delegated by the Session Controller. */ +export class SessionHistoryController { + private readonly closeFollowers = new Set<() => void>(); + private readonly assistantStreams = new Map(); + + /** + * @param ctx - Host context carrying Session query and projection services. + * @param promote - starts ordinary Session activation after snapshot delivery. + */ + constructor( + private readonly ctx: Context, + private readonly promote: (observation: SessionObservation) => void, + ) { + ctx.on( + "agent/assistant-stream", + ({ agent, frame }) => { + let stream = this.assistantStreams.get(agent.session.id); + if (stream === undefined) { + stream = new SessionAssistantStreamAccumulator(); + this.assistantStreams.set(agent.session.id, stream); + } + stream.accept(frame, cursorBeforeNext(agent.session.seq)); + }, + { global: true }, + ); + ctx.on( + "agent/disposed", + ({ agent }) => { + this.assistantStreams.delete(agent.session.id); + }, + { global: true }, + ); + ctx.effect( + () => () => { + for (const close of this.closeFollowers) close(); + this.closeFollowers.clear(); + }, + "session-controller.history", + ); + } + + /** + * Read one message-aligned history page without activating an Agent. + * @param request - durable address and backwards-page cursor. + * @param signal - caller cancellation for persistence reads. + * @returns a contiguous event page. + */ + async page(request: SessionPageRequest, signal: AbortSignal): Promise { + validatePageRequest(request); + const throughSeq: SessionSeqCursor = + request.throughSeq === -1 ? -1 : SessionSeq(request.throughSeq); + const beforeSeq = + request.beforeSeq === undefined ? undefined : SessionLogOffset(request.beforeSeq); + using source = await this.sourceFor(request.address, signal, false); + signal.throwIfAborted(); + const sourceLog = source.events; + const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1; + if (throughSeq > sourceCursor) { + throw new RemoteError( + "gateway/bad-request", + `session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`, + {}, + ); + } + /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ + if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) { + throw new RemoteError( + "gateway/internal", + `session log does not contain through seq ${String(throughSeq)}`, + {}, + ); + } + const page = paginate( + sourceLog, + beforeSeq, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + throughSeq, + request.turnWindow, + ); + const records = pageRecords(page.events); + return { + records, + hasMore: page.hasMore, + }; + } + + /** + * Follow events appended after an initial cursor on one durable address. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - stream cancellation owned by the Remote carrier. + * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. + */ + async *follow( + request: SessionFollowRequest, + signal: AbortSignal, + ): AsyncIterable { + validateHistoryWindow(request); + const { address } = request; + const target = addressId(address); + const buffered = new Deque< + | { readonly type: "event"; readonly event: SessionEvent } + | { + readonly type: "assistant-stream"; + readonly frame: SessionAssistantStreamFrame; + readonly ordinal: number; + } + >(); + let snapshotCursor: SessionSeqCursor | undefined; + let assistantStreamOrdinal = 0; + let wake: (() => void) | undefined; + const notify = (): void => { + const resume = wake; + wake = undefined; + resume?.(); + }; + const follower = { closed: false }; + const close = (): void => { + follower.closed = true; + notify(); + }; + this.closeFollowers.add(close); + const disposeEvent = this.ctx.on( + "session/event", + (session, event) => { + if (session.id !== target) return; + buffered.pushBack({ type: "event", event }); + notify(); + }, + { global: true }, + ); + const disposeCreated = this.ctx.on( + "session/created", + (session) => { + if (session.id !== target) return; + // Constructor seed events have no session/event notification. Normally + // only the end-seed suffix is new; if persistence advanced after the + // opening observation, replay everything beyond that snapshot cursor. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const suffix = session.snapshotEvents( + snapshotCursor === undefined + ? session.firstLiveSeq + : SessionLogOffset(snapshotCursor + 1), + ); + for (let index = suffix.length - 1; index >= 0; index -= 1) { + buffered.pushFront({ type: "event", event: suffix[index] as SessionEvent }); + } + notify(); + }, + { global: true }, + ); + const disposeAssistantStream = + request.assistantStream !== true + ? undefined + : this.ctx.on( + "agent/assistant-stream", + ({ agent, frame }) => { + if (agent.session.id !== target) return; + buffered.pushBack({ + type: "assistant-stream", + frame: wireAssistantStreamFrame(frame, cursorBeforeNext(agent.session.seq)), + ordinal: ++assistantStreamOrdinal, + }); + notify(); + }, + { global: true }, + ); + const onAbort = (): void => { + notify(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + try { + using source = await this.sourceFor(address, signal, true); + const events = source.events; + signal.throwIfAborted(); + const cursor = source.cursor; + snapshotCursor = cursor; + const page = paginate( + events, + undefined, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + cursor, + request.turnWindow, + ); + const assistantStream = + request.assistantStream === true + ? (this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }) + : undefined; + // The accumulator snapshot and this watermark are synchronous. Frames + // through the cut are represented or superseded by that baseline, + // including larger revisions from a retired Agent; later revision + // resets reach Client continuity validation. + const assistantStreamOrdinalCut = assistantStreamOrdinal; + yield { + type: "snapshot", + header: wireHeader(source.header), + cursor, + records: pageRecords(page.events), + hasMore: page.hasMore, + projections: + source.projections === undefined + ? { asOfSeq: cursor, values: {} } + : projectionBlock(source.projections), + ...(assistantStream === undefined ? {} : { assistantStream }), + }; + if (address.kind === "session" && source.source === "prepared") { + const promotion = source.retain(); + try { + this.promote(promotion); + } catch (error: unknown) { + promotion[Symbol.dispose](); + throw error; + } + } + let nextOffset = SessionLogOffset(cursor + 1); + while (!follower.closed && !signal.aborted) { + const item = buffered.popFront(); + if (item === undefined) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + if (item.type === "assistant-stream") { + if (item.ordinal > assistantStreamOrdinalCut) { + yield { type: "assistant-stream", frame: item.frame }; + } + continue; + } + const expectedSeq = SessionSeq(nextOffset); + if (item.event.seq < expectedSeq) continue; + if (item.event.seq !== expectedSeq) { + throw new RemoteError( + "gateway/internal", + `session event stream skipped seq ${String(expectedSeq)}`, + {}, + ); + } + nextOffset = SessionLogOffset(nextOffset + 1); + yield entryFor(item.event); + } + } finally { + this.closeFollowers.delete(close); + signal.removeEventListener("abort", onAbort); + disposeCreated(); + disposeEvent(); + disposeAssistantStream?.(); + } + } + + private async sourceFor( + address: SessionAddress, + signal: AbortSignal, + withProjections: boolean, + ): Promise { + const sessionId = addressId(address); + try { + const observation = await this.ctx.sessionQuery.observeSession(sessionId, { + signal, + projectionMode: withProjections || address.kind === "subagent" ? "all" : "none", + }); + if (observation.header.cwd === undefined) { + observation[Symbol.dispose](); + rejectNotFound(address); + } + try { + validateAddress( + address, + observation.header, + observation.inheritedEventCount, + observation.projections, + ); + } catch (error: unknown) { + observation[Symbol.dispose](); + throw error; + } + return observation; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") + rejectNotFound(address); + throw error; + } + } +} + +function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor { + return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1); +} + +function wireAssistantStreamFrame( + frame: AssistantStreamFrame, + durableCursor: SessionSeqCursor, +): SessionAssistantStreamFrame { + if (frame.type === "start") return { ...frame, startedAfterSeq: durableCursor }; + if (frame.type === "end") return frame; + return { + ...frame, + chunk: frame.chunk as JsonValue, + }; +} + +function projectionBlock( + snapshot: NonNullable, +): SessionProjectionBaseline { + return { + asOfSeq: snapshot.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: snapshot.values as SessionProjectionValues, + }; +} + +function validatePageRequest(request: SessionPageRequest): void { + if ( + !Number.isSafeInteger(request.throughSeq) || + request.throughSeq < -1 || + Object.is(request.throughSeq, -0) + ) { + throw new RemoteError( + "gateway/bad-request", + "throughSeq must be an integer greater than or equal to -1", + {}, + ); + } + if ( + request.beforeSeq !== undefined && + (!Number.isSafeInteger(request.beforeSeq) || + request.beforeSeq < 0 || + Object.is(request.beforeSeq, -0)) + ) { + throw new RemoteError( + "gateway/bad-request", + "beforeSeq must be a non-negative safe integer", + {}, + ); + } + validateHistoryWindow(request); +} + +function validateHistoryWindow( + request: Pick, +): void { + if ( + request.maxMessages !== undefined && + (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0) + ) { + throw new RemoteError("gateway/bad-request", "maxMessages must be a positive safe integer", {}); + } + const window = request.turnWindow; + if (window !== undefined) { + if ( + !Number.isSafeInteger(window.minMessages) || + window.minMessages <= 0 || + window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES) + ) { + throw new RemoteError( + "gateway/bad-request", + "turnWindow.minMessages must be a positive safe integer no greater than maxMessages", + {}, + ); + } + if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) { + throw new RemoteError( + "gateway/bad-request", + "turnWindow.minTurns must be a positive safe integer", + {}, + ); + } + } +} + +function addressId(address: SessionAddress): SessionId { + return address.kind === "session" ? address.sessionId : address.childSessionId; +} + +function validateAddress( + address: SessionAddress, + header: SessionHeader, + inheritedEventCount: SessionLogOffsetType, + projections: SessionObservation["projections"], +): void { + if (address.kind === "session") { + if (header.origin === "subagent") { + throw new RemoteError( + "session/agent-busy", + "subagent Sessions require their durable parent address", + { + reason: "use subagent delivery for this child session", + }, + ); + } + return; + } + if (header.origin !== "subagent" || header.parentSession !== address.parentSessionId) { + throw new RemoteError( + "subagent/unauthorized", + "subagent does not belong to the supplied parent", + { + childSessionId: address.childSessionId, + }, + ); + } + const identity = projections?.values.subagent; + if (identity === null) { + throw new RemoteError("subagent/catalog-diagnostic", "subagent descriptor is corrupt", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: "corrupt", + }); + } + if (identity === undefined || identity.seq < inheritedEventCount) { + throw new RemoteError("subagent/catalog-diagnostic", "subagent descriptor is unavailable", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: "unsupported", + }); + } + if (address.mode !== "unknown" && identity.mode !== address.mode) { + throw new RemoteError( + "subagent/unauthorized", + "subagent mode does not match the supplied address", + { + childSessionId: address.childSessionId, + }, + ); + } +} + +function rejectNotFound(address: SessionAddress): never { + if (address.kind === "session") { + throw new RemoteError("session/not-found", `session "${address.sessionId}" not found`, { + sessionId: address.sessionId, + }); + } + throw new RemoteError("subagent/not-found", "subagent is unavailable", { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + }); +} + +function paginate( + events: readonly SessionEvent[], + beforeSeq: SessionLogOffsetType | undefined, + maxMessages: number, + throughSeq: SessionSeqCursor, + turnWindow?: SessionPageRequest["turnWindow"], +): { readonly events: SessionEvent[]; readonly hasMore: boolean } { + const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)); + let count = 0; + let turns = 0; + let cut = SessionLogOffset(0); + for (let index = end - 1; index >= 0; index--) { + const event = events[index] as SessionEvent; + if (turnWindow !== undefined && event.type === "turn/start") { + turns++; + if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) { + cut = SessionLogOffset(index); + break; + } + } + if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue; + count++; + const sources = event.sourceEventSeqs; + let groupStart = event.seq; + if (sources !== undefined) { + for (const source of sources) { + if (source < groupStart) groupStart = source; + } + } + if (count >= maxMessages) { + cut = SessionLogOffset(groupStart); + break; + } + } + return { events: events.slice(cut, end), hasMore: cut > 0 }; +} + +/** Translate current logical Session metadata to the browser wire. */ +function wireHeader(header: SessionHeader): SessionWireHeader { + return { ...header }; +} + +function entryFor(event: SessionEvent): SessionEventEntry { + return { + type: "event", + // Session.append validates and freezes event data as JSON before publication. + event: event as unknown as SessionWireEvent, + }; +} + +/** Encode one bounded logical page without changing its pagination cut. */ +function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { + return events.map(entryFor); +} diff --git a/apps/web-ui/packages/api/session-controller/src/index.ts b/apps/web-ui/packages/api/session-controller/src/index.ts new file mode 100644 index 000000000..dba0bb43c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/index.ts @@ -0,0 +1,591 @@ +/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ + +import { hostname } from "node:os"; +import { resolve } from "node:path"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type {} from "@deepseek-ai/dsh-fs"; +import { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import { errorChain, ReasoningEffortId } from "@deepseek-ai/dsh-llm"; +import type {} from "@deepseek-ai/dsh-client-file-upload"; +import { + canOpenNativePath, + nativeFileManager, + nativeFileApplications, + openNativeFileApplication, + openNativeAssociatedPath, + revealNativePath, +} from "@deepseek-ai/dsh-native-command"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type { SessionInspection } from "@deepseek-ai/dsh-session-persistence"; +import { SessionQueryError, type SessionObservation } from "@deepseek-ai/dsh-session-query"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { + ApiSessionAgentController, + inspectApiSession, + type ApiSessionAgentResult, +} from "./agent.ts"; +import { SessionCommandController } from "./commands.ts"; +import { SessionControlController } from "./control.ts"; +import { SessionHistoryController } from "./history.ts"; +import { SessionFileReferences } from "./file-references.ts"; +import { ApiSessionList } from "./list.ts"; +import { buildModelCatalog, hasProviderApiKey } from "./catalog.ts"; +import { installModelSelectionProjection } from "./model-selection-projection.ts"; +import { SessionSkillCatalog } from "./skill-catalog.ts"; +import { SessionMediaReferences } from "./media-references.ts"; +import { ArchivedSessionGate } from "./archived-session-gate.ts"; +import type { + ModelCatalog, + SessionWorkspacePathApplication, + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionFollowFrame, + SessionFollowRequest, + SessionForkRequest, + SessionForkValue, + SessionListRequest, + SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionProjectionsRequest, + SessionProjectionsValue, + SessionProjectionValues, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from "./types.ts"; + +export type * from "./types.ts"; +export { ApiSessionNotFound } from "./agent.ts"; +export { SessionFileReferences } from "./file-references.ts"; +export { SessionSkillCatalog } from "./skill-catalog.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Session business API and Remote namespace owner. */ + sessionController: SessionController; + } +} + +/** Session Controller deployment policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean; + /** Positive integral milliseconds of list work before yielding between complete rows. */ + readonly listWorkSliceMs?: number; +} + +/** Deployment policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { readonly listWorkSliceMs: number }; + +/** Host integrations replaceable by direct unit tests. */ +export interface SessionControllerInternals { + /** Native default-application handoff. */ + readonly openPath?: (path: string, signal: AbortSignal) => Promise; + /** Native file-association query. */ + readonly fileApplications?: typeof nativeFileApplications; + /** Explicit registered-application handoff. */ + readonly openFileApplication?: typeof openNativeFileApplication; + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise; + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean; +} + +/** Host service backing the generated `ctx.remote.session` namespace. */ +export class SessionController extends TypertRemoteService { + static inject = [ + "agentDefaultModel", + "agents", + "attachments", + "fileUploads", + "fs", + "llm", + "sessions", + "sessionProjections", + "sessionQuery", + "typert", + "workspaceRegistry", + ]; + + static Config: z = z.object({ + nativeOpen: z.boolean(), + listWorkSliceMs: z.natural().min(1).default(16), + }); + + private readonly agents: ApiSessionAgentController; + private readonly commands: SessionCommandController; + private readonly controlState: SessionControlController; + private readonly history: SessionHistoryController; + private readonly listState: ApiSessionList; + private readonly openPath: (path: string, signal: AbortSignal) => Promise; + private readonly fileApplications: typeof nativeFileApplications; + private readonly openFileApplication: typeof openNativeFileApplication; + private readonly revealPath: (path: string, signal: AbortSignal) => Promise; + private readonly canOpenPath: () => boolean; + private readonly promotions = new Set>(); + + /** + * @param ctx - Host context containing the Session capability assembly. + * @param config - native-opener and list-scheduling deployment policy. + * @param internals - host integrations replaceable by direct unit tests. + */ + constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { + super(ctx, "sessionController", { namespace: "session" }); + const resolved = SessionController.Config(config); + installModelSelectionProjection(ctx); + this.agents = new ApiSessionAgentController(ctx); + this.commands = new SessionCommandController(ctx, this.agents, process.cwd()); + ctx.effect( + () => + ctx.fileUploads.registerAgentResolver(async (sessionId) => { + const result = await this.agents.resolveAgent(sessionId); + if ("error" in result) throw result.error; + return result.agent; + }), + "session-controller: file-upload Agent resolver", + ); + this.controlState = new SessionControlController(ctx); + // Registered before history so reverse-order teardown closes every + // follower before waiting for already-admitted promotions. + ctx.effect( + () => async () => { + await Promise.allSettled([...this.promotions]); + }, + "session-controller.promotions", + ); + this.history = new SessionHistoryController(ctx, (observation) => { + this.promote(observation); + }); + this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs); + this.fileApplications = internals.fileApplications ?? nativeFileApplications; + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication; + this.openPath = internals.openPath ?? openNativeAssociatedPath; + this.revealPath = internals.revealPath ?? revealNativePath; + this.canOpenPath = + internals.canOpenPath ?? + (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())); + ctx.plugin(SessionFileReferences); + ctx.plugin(SessionMediaReferences); + ctx.plugin(SessionSkillCatalog); + // An archived Session, or a subagent descendant of one, runs no model step + // until it is restored; what it still runs is stopped by the owners that + // answer the Workspace registry's archive-admission events. + ctx.plugin(ArchivedSessionGate); + + ctx.on("session/created", (session) => { + ctx.emit("api-session/added", this.listState.summaryFor(session)); + }); + ctx.on("session/disposed", (session) => { + ctx.emit("api-session/removed", session.id); + }); + const publishAgentAvailability = ({ agent }: { agent: Agent }): undefined => { + if (ctx.sessions.get(agent.id) === agent.session) { + ctx.emit("api-session/added", this.listState.summaryFor(agent.session)); + } + }; + ctx.on("agent/created", publishAgentAvailability); + ctx.on("agent/disposed", publishAgentAvailability); + ctx.on("agent/status", ({ agent, status }) => { + ctx.emit("api-session/status", agent.id, status === "running"); + }); + ctx.on("agent/error", ({ agent, error }) => { + ctx.emit("api-session/error", agent.id, errorChain(error)); + }); + ctx.on("session/event", (session, event) => { + if (event.type === "request/header") { + const agent = ctx.agents.get(session.id); + if (agent?.session === session) + this.agents.consumeSelection( + agent, + event.data.header.config.provider, + event.data.header.config.model, + event.data.header.config.reasoningEffort, + ); + } + if (event.type !== "user/message" || event.data.source.kind !== "user") return; + ctx.emit("api-session/activity", session.id, event.time); + }); + } + + private promote(observation: SessionObservation): void { + const sessionId = observation.header.id; + const task = (async () => { + using ownedObservation = observation; + const result = await this.agents.resolveObservedAgent(ownedObservation); + if ("error" in result) this.ctx.emit("api-session/error", sessionId, result.error.message); + })().catch((error: unknown) => { + this.ctx.logger.error( + `session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`, + ); + }); + this.promotions.add(task); + void task.finally(() => { + this.promotions.delete(task); + }); + } + + /** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ + resolveAgent(sessionId: SessionId): Promise { + return this.agents.resolveAgent(sessionId); + } + + /** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ + inspect(sessionId: SessionId, signal?: AbortSignal): Promise { + const attached = this.ctx.sessions.get(sessionId); + if (attached !== undefined) { + return Promise.resolve({ + meta: attached.header, + inheritedEventCount: attached.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + events: attached.snapshotEvents(), + }); + } + return inspectApiSession(this.ctx, sessionId, signal); + } + + /** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads and summary generation. + * @returns visible Session summaries ordered by activity. + */ + @Remote("list") + async list(_request: SessionListRequest, signal: AbortSignal): Promise { + return { items: await this.listState.list(signal) }; + } + + /** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + @Remote("search") + search(request: SessionSearchRequest, signal: AbortSignal): Promise { + return this.listState.search(request.query, signal); + } + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + @Remote("create") + create(request: SessionCreateRequest): Promise { + return this.commands.create(request); + } + + /** + * Select one Session-local model after explicitly resuming the Session; save the default in the background. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session, without waiting for default persistence. + */ + @Remote("selectModel") + selectModel(request: SessionSelectModelRequest): Promise { + return this.commands.selectModel(request); + } + + /** + * Select the first available account model after login when no provider API key is configured. + * @returns after saving the first available model or retaining the existing default. + */ + @Remote + async initializeDefaultModel(): Promise { + const provider = "deepseek-account"; + if (await hasProviderApiKey(this.ctx)) return; + const catalog = await buildModelCatalog(this.ctx); + const model = catalog.groups.find((group) => group.id === provider)?.models[0]; + if (model === undefined) + throw new RemoteError( + "session/provider-models-unavailable", + `provider "${provider}" has no available models`, + { provider }, + ); + const selection = { + provider, + model: model.id, + ...(model.reasoning?.defaultEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }), + }; + await this.ctx.agentDefaultModel.saveSelection(selection); + } + + /** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ + @Remote("modelCatalog") + modelCatalog(): Promise { + return buildModelCatalog(this.ctx); + } + + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath(); + } + + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { + name: string; + available: boolean; + fileManager: "finder" | "explorer" | "directory" | null; + } { + const fileManager = nativeFileManager(); + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager }; + } + + /** + * Verify one path through the composed filesystem and open it on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. + * @returns confirmation after the native opener accepts the path. + * @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails. + */ + @Remote("openWorkspacePath") + async openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal: AbortSignal, + ): Promise { + try { + const path = await this.verifyDesktopPath(request.path, signal); + if (request.action === "reveal") await this.revealPath(path, signal); + else if (request.application !== undefined) + await this.openFileApplication(path, request.application, signal); + else await this.openPath(path, signal); + return { opened: true }; + } catch (error: unknown) { + if (signal.aborted) throw new RemoteError("gateway/cancelled", "path open was aborted", {}); + if (error instanceof RemoteError) throw error; + throw new RemoteError("gateway/internal", "path open failed", {}, { cause: error }); + } + } + + /** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ + @Remote("workspacePathApplications") + async workspacePathApplications( + request: { readonly path: string }, + signal: AbortSignal, + ): Promise { + if (!this.canOpenPath()) return []; + try { + const path = await this.verifyDesktopPath(request.path, signal); + return await this.fileApplications(path, signal); + } catch (error: unknown) { + if (signal.aborted) + throw new RemoteError("gateway/cancelled", "application query was aborted", {}); + if (error instanceof RemoteError) throw error; + throw new RemoteError( + "gateway/internal", + "file application query failed", + {}, + { cause: error }, + ); + } + } + + private async verifyDesktopPath(path: string, signal: AbortSignal): Promise { + if (path.length === 0) + throw new RemoteError("gateway/bad-request", "A non-empty file path is required", {}); + signal.throwIfAborted(); + const hostPath = resolve(path); + const { fs } = this.ctx; + const mapped = fs.processPathFromHostPath(hostPath); + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { + throw new RemoteError("gateway/bad-request", "Path has no verified Host path", {}); + } + signal.throwIfAborted(); + return hostPath; + } + + /** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + @Remote("rename") + rename(request: SessionRenameRequest): Promise { + return this.commands.rename(request); + } + + /** + * Fork one cold-readable exact event prefix into a new Session. An omitted + * boundary selects the latest completed-turn prefix; an open cut receives + * synthetic fork closers. + * @param request - source Session and optional exact inclusive event boundary. + * @returns the new Session identity. + */ + @Remote("fork") + fork(request: SessionForkRequest): Promise { + return this.commands.fork(request); + } + + /** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ + @Remote("prompt") + prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return this.commands.prompt(request); + } + + /** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + @Remote("attachment") + attachment(request: SessionAttachmentRequest): Promise { + return this.commands.attachment(request); + } + + /** + * Mutate one still-pending queue occurrence, resuming a cold Agent first. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + @Remote("updateQueue") + updateQueue(request: SessionUpdateQueueRequest): Promise { + return this.commands.updateQueue(request); + } + + /** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + @Remote("cancel") + cancel(request: SessionCancelRequest): SessionCancelValue { + return this.commands.cancel(request); + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page. + */ + @Remote("page") + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal); + } + + /** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns a complete opening snapshot followed by gap-free durable event + * frames and optional cursorless assistant-stream frames. + */ + @Remote({ mode: "stream" }) + follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + return this.history.follow(request, signal); + } + + /** + * Read all registered projections without activating an Agent. + * @param request - Session whose current values are required. + * @param signal - cancellation for the Session observation. + * @returns complete baseline, or null when the Session does not exist. + */ + @Remote("projections") + async projections( + request: SessionProjectionsRequest, + signal: AbortSignal, + ): Promise { + const { sessionId } = request; + if (sessionId.length === 0) { + throw new RemoteError("gateway/bad-request", "sessionId must not be empty", {}); + } + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }); + const projections = observation.projections; + if (projections === undefined) { + throw new RemoteError( + "session/projections-unavailable", + "Session projections are unavailable", + {}, + ); + } + return { + asOfSeq: projections.asOfSeq, + values: projections.values as SessionProjectionValues, + }; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") + return null; + if ( + signal.aborted || + (error instanceof SessionQueryError && error.code === "SESSION_QUERY_ABORTED") + ) { + throw new RemoteError( + "gateway/cancelled", + "Session projection read was cancelled", + {}, + { cause: error }, + ); + } + if (error instanceof RemoteError) throw error; + throw new RemoteError( + "gateway/internal", + "Session projection read failed", + {}, + { cause: error }, + ); + } + } + + /** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ + @Remote({ mode: "stream" }) + control(signal: AbortSignal): AsyncIterable { + return this.controlState.control(signal); + } +} + +export { buildModelCatalog }; +export default SessionController; diff --git a/apps/web-ui/packages/api/session-controller/src/list.ts b/apps/web-ui/packages/api/session-controller/src/list.ts new file mode 100644 index 000000000..28a214427 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/list.ts @@ -0,0 +1,388 @@ +/** Cold-safe Session list and search projection. */ + +import { performance } from "node:perf_hooks"; +import { scheduler } from "node:timers/promises"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry"; +import type { ImageAttachmentLimits } from "@deepseek-ai/dsh-attachment"; +import type { Session, SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session"; +import type { ProjectionSnapshot } from "@deepseek-ai/dsh-session-projection"; +import type {} from "@deepseek-ai/dsh-session-projection-cache"; +import { SessionQueryError, type SessionSearchCursor } from "@deepseek-ai/dsh-session-query"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import { z } from "zod"; +import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS } from "./types.ts"; +import type { + SessionListMetadata, + SessionProjectionHints, + SessionProjectionValues, + SessionSearchItem, + SessionSearchValue, + SessionSummary, +} from "./types.ts"; + +const SEARCH_PROVIDER_CALL_LIMIT = 100; +const SESSION_SEARCH_QUERY_MAX_CHARS = 500; +const MESSAGE_TYPES = new Set(["user/message", "assistant/message"]); + +const sessionListMetadataSchema: z.ZodType = z.object({ + blank: z.boolean(), + lastPromptAt: z.number().nullable(), +}); + +const imageLimitsSchema = z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), + mediaTypes: z.array(z.string()), +}) as unknown as z.ZodType; + +/** + * Advance the Session-list metadata projection by one committed event. + * @param state - metadata before the event. + * @param event - next committed Session event. + * @returns the original or advanced metadata value. + */ +export function applySessionListMetadata( + state: SessionListMetadata, + event: SessionEvent, +): SessionListMetadata { + const blank = state.blank && event.type !== "turn/start"; + const lastPromptAt = + event.type === "user/message" && event.data.source.kind === "user" + ? event.time + : state.lastPromptAt; + return blank === state.blank && lastPromptAt === state.lastPromptAt + ? state + : { blank, lastPromptAt }; +} + +/** + * Return the longest prefix containing at most `maximum` Unicode code points. + * @param value - source text. + * @param maximum - maximum number of Unicode code points. + * @returns the source text or its longest allowed prefix. + */ +export function truncateUnicodeCodePoints(value: string, maximum: number): string { + let count = 0; + let end = 0; + for (const codePoint of value) { + if (count === maximum) return value.slice(0, end); + count++; + end += codePoint.length; + } + return value; +} + +/** Owns list projection registration, bounded cold summaries, and authorized search. */ +export class ApiSessionList { + /** + * @param ctx - Host context carrying Session, query, persistence, and projection services. + * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. + */ + constructor( + private readonly ctx: Context, + private readonly workSliceMs: number, + ) { + ctx.sessionProjections.register<"sessionListMetadata", SessionListMetadata>({ + key: "sessionListMetadata", + stateSchema: sessionListMetadataSchema, + init: () => ({ blank: true, lastPromptAt: null }), + apply: applySessionListMetadata, + wire: { viewSchema: sessionListMetadataSchema, view: (state) => state }, + stateVersion: 1, + }); + ctx.inject(["attachments"], (attachmentCtx) => { + ctx.sessionProjections.register<"imageLimits", null>({ + key: "imageLimits", + stateSchema: z.null(), + init: () => null, + apply: (state) => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => attachmentCtx.attachments.imageLimits, + }, + stateVersion: 1, + }); + }); + } + + /** + * Build one current attached-Session summary. + * @param session - attached Session to summarize. + * @returns current list metadata and available projections. + */ + summaryFor(session: Session): SessionSummary { + const projections = this.projectionsFor(session.header, session); + const metadata = projections?.values.sessionListMetadata; + return { + sessionId: session.id, + updatedAt: updatedAt(session.header, metadata), + agentAvailable: this.ctx.agents.get(session.id)?.session === session, + running: this.ctx.agents.get(session.id)?.status === "running", + blank: metadata?.blank ?? session.seq === 0, + ...listFields(session.header), + ...(projections === undefined ? {} : { projections }), + }; + } + + /** + * Read every visible attached and persisted Session without activating an Agent. + * @param signal - optional cancellation for persistence reads and summary generation. + * @returns visible Session summaries ordered by activity. + */ + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const records = await this.ctx.sessionQuery.listSessions(signal); + signal?.throwIfAborted(); + const items: SessionSummary[] = []; + const cold: SessionHeader[] = []; + let yieldDeadline = performance.now() + this.workSliceMs; + for (const record of records) { + signal?.throwIfAborted(); + const live = this.ctx.sessions.get(record.header.id); + if (live !== undefined) { + items.push(this.summaryFor(live)); + } else if (record.header.cwd !== undefined) { + cold.push(record.header); + } + if (performance.now() >= yieldDeadline) { + await scheduler.yield(); + signal?.throwIfAborted(); + yieldDeadline = performance.now() + this.workSliceMs; + } + } + for (const header of cold) { + signal?.throwIfAborted(); + items.push(this.summarizeCold(header)); + if (performance.now() >= yieldDeadline) { + await scheduler.yield(); + signal?.throwIfAborted(); + yieldDeadline = performance.now() + this.workSliceMs; + } + } + signal?.throwIfAborted(); + items.sort((left, right) => right.updatedAt - left.updatedAt); + return items; + } + + private summarizeCold(header: SessionHeader): SessionSummary { + const projections = this.projectionsFor(header, undefined); + const metadata = projections?.values.sessionListMetadata; + return { + sessionId: header.id, + updatedAt: updatedAt(header, metadata), + agentAvailable: false, + running: false, + // A large, metadata-less, or inaccessible cache miss remains unknown and visible. + blank: metadata?.blank ?? false, + ...listFields(header), + ...(projections === undefined ? {} : { projections }), + }; + } + + /** + * Search current visible message content without activating any matching Session. + * @param query - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + async search(query: string, signal: AbortSignal): Promise { + const normalizedQuery = normalizeSearchQuery(query); + signal.throwIfAborted(); + const provider = this.ctx.get("sessionQuery"); + if (provider === undefined) { + throw new RemoteError( + "gateway/internal", + "session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query", + {}, + ); + } + try { + const visible = await provider.listSessions(signal); + signal.throwIfAborted(); + const visibleIds = new Set( + visible + .filter((record) => record.header.cwd !== undefined) + .map((record) => record.header.id), + ); + if (visibleIds.size === 0) return { items: [], hasMore: false }; + const authorized: SessionSearchItem[] = []; + const acceptedIds = new Set(); + const seenCursors = new Set(); + let cursor: SessionSearchCursor | undefined; + let providerCalls = 0; + let pageLimit = SESSION_SEARCH_RESULT_LIMIT; + while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { + signal.throwIfAborted(); + if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) { + throw new Error( + `session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`, + ); + } + providerCalls++; + const requestedCursor = cursor; + const requestedLimit = pageLimit; + let page; + try { + page = await provider.searchSessions( + { + query: normalizedQuery, + eventFilters: [ + { kind: "type", values: ["user/message", "assistant/message"] }, + { kind: "surface", values: ["current"] }, + ], + limit: requestedLimit, + ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), + }, + { signal }, + ); + signal.throwIfAborted(); + } catch (error: unknown) { + signal.throwIfAborted(); + if ( + requestedCursor === undefined && + error instanceof SessionQueryError && + error.code === "SESSION_QUERY_INVALID_LIMIT" && + requestedLimit > 1 + ) { + pageLimit = Math.max(1, Math.floor(requestedLimit / 2)); + continue; + } + if ( + requestedCursor !== undefined && + error instanceof SessionQueryError && + error.code === "SESSION_QUERY_STALE_CURSOR" + ) { + authorized.length = 0; + acceptedIds.clear(); + seenCursors.clear(); + cursor = undefined; + continue; + } + throw error; + } + if (page.items.length > requestedLimit) { + throw new Error( + `session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`, + ); + } + for (const hit of page.items) { + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue; + if ( + !visibleIds.has(hit.header.id) || + hit.bestMatch.sessionId !== hit.header.id || + hit.bestMatch.surface !== "current" || + !MESSAGE_TYPES.has(hit.bestMatch.type) || + acceptedIds.has(hit.header.id) + ) + continue; + acceptedIds.add(hit.header.id); + authorized.push({ + sessionId: hit.header.id, + snippet: truncateUnicodeCodePoints( + hit.bestMatch.snippet, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, + ), + }); + } + if (page.nextCursor !== undefined) { + if (seenCursors.has(page.nextCursor)) { + throw new Error("session search provider repeated a continuation cursor"); + } + seenCursors.add(page.nextCursor); + } + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break; + cursor = page.nextCursor; + } + return { + items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, + }; + } catch (error: unknown) { + signal.throwIfAborted(); + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_ABORTED") { + throw new RemoteError("gateway/cancelled", "session search was aborted", {}); + } + throw new RemoteError("gateway/internal", `session search failed: ${String(error)}`, {}); + } + } + + private projectionsFor( + header: SessionHeader, + session: Session | undefined, + ): SessionProjectionHints | undefined { + try { + if (session !== undefined) { + // The live registry computed the block for this Session: its watermark + // shares the sequence space of the Session's baselines and frames. + return hintsOf("sequenced", this.ctx.sessionProjections.cachedSnapshot(session)); + } + // A cold row reads the persisted cache by header alone; the cache serves + // seeded and unseeded lifecycles alike because a listing never seeds a + // fold. The watermark is the stored record's own. + const cache = this.ctx.get("sessionProjectionCache"); + return hintsOf( + "cached", + cache?.cachedSnapshot(header) ?? cache?.cachedPredecessorTitle(header), + ); + } catch (error) { + this.ctx.logger.warn( + `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, + ); + return undefined; + } + } +} + +/** + * Wrap one projection block as Session-list hints of the named sequence space. + * @param kind - which sequence space the block's watermark belongs to. + * @param block - the block, or `undefined` when no source served one. + * @returns the hints, or `undefined` when the block is absent or carries no value. + */ +function hintsOf( + kind: SessionProjectionHints["kind"], + block: ProjectionSnapshot | undefined, +): SessionProjectionHints | undefined { + if (block === undefined || Object.keys(block.values).length === 0) return undefined; + // Listing hints contain every wire value the source currently holds but + // remain partial: missing cells and cache rows are never materialized here. + return { kind, asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }; +} + +function normalizeSearchQuery(query: string): string { + const normalized = query.trim(); + if (normalized.length === 0) { + throw new RemoteError("gateway/bad-request", "session search query must not be empty", {}); + } + if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { + throw new RemoteError( + "gateway/bad-request", + `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, + {}, + ); + } + if (normalized.includes("\0")) { + throw new RemoteError("gateway/bad-request", "session search query must not contain NUL", {}); + } + return normalized; +} + +function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { + return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0); +} + +function listFields(header: SessionHeader): { + readonly parentSessionId?: SessionId; + readonly origin?: "subagent"; + readonly cwd?: string; +} { + return { + ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }), + ...(header.origin === undefined ? {} : { origin: header.origin }), + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }; +} diff --git a/apps/web-ui/packages/api/session-controller/src/media-references.ts b/apps/web-ui/packages/api/session-controller/src/media-references.ts new file mode 100644 index 000000000..d38cda72e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/media-references.ts @@ -0,0 +1,81 @@ +/** + * Authenticated GET/HEAD /api/file reads bounded file responses through + * the composed filesystem provider. Paths and MIME types do not restrict access; + * the connection service authenticates requests before this handler. + * @module @deepseek-ai/dsh-api-session-controller/media-references + */ + +import { isAbsolute } from "node:path"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-client-connection"; +import type {} from "@deepseek-ai/dsh-attachment"; +import { FsError, type FileSystem } from "@deepseek-ai/dsh-fs"; +import mime from "mime-types"; + +const BASE_HEADERS = { + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + // HTML and SVG files may be opened directly on the authenticated API origin. + "Content-Security-Policy": "sandbox; default-src 'none'", +}; + +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { + const fail = (status: number, text: string): Response => + new Response(request.method === "HEAD" ? null : text, { status, headers: BASE_HEADERS }); + const path = new URL(request.url).searchParams.get("path"); + if (path === null || path.length === 0) return fail(400, "missing path"); + if (path.includes("\0") || !isAbsolute(path)) return fail(400, "absolute path required"); + try { + const target = await fs.resolve(path, { signal: request.signal }); + const mediaType = mime.lookup(target.displayPath) || "application/octet-stream"; + const headers: Record = { + ...BASE_HEADERS, + "Content-Type": mediaType, + }; + if (request.method === "HEAD") { + const info = await fs.stat(target, request.signal); + if (info === undefined) return fail(404, "not found"); + if (info.type !== "file") return fail(403, "not a regular file"); + if (info.size !== undefined) { + if (info.size > maxBytes) return fail(413, "file exceeds byte limit"); + headers["Content-Length"] = String(info.size); + } + return new Response(null, { headers }); + } + const bytes = await fs.readBytes(target, request.signal, maxBytes); + headers["Content-Length"] = String(bytes.byteLength); + return new Response(bytes.slice(), { headers }); + } catch (error: unknown) { + if (!(error instanceof FsError)) throw error; + const statuses: Partial> = { + FS_NOT_FOUND: 404, + FS_NOT_REGULAR_FILE: 403, + FS_PERMISSION_DENIED: 403, + FS_SANDBOX_DENIED: 403, + FS_TOO_LARGE: 413, + FS_ABORTED: 499, + }; + return fail(statuses[error.code] ?? 500, error.code); + } +} + +/** + * File-display contribution. The connection service supplies authentication; + * `ctx.fs` supplies the execution world's paths, reads, and access policy. + */ +export const SessionMediaReferences = { + inject: ["connection", "fs", "attachments"], + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes; + ctx.effect( + () => + ctx.connection.fetch.register({ + path: "/api/file", + methods: ["GET", "HEAD"], + requestBody: "buffered", + fetch: (request) => serveFile(request, ctx.fs, maxBytes), + }), + "session-controller: /api/file", + ); + }, +}; diff --git a/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts b/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts new file mode 100644 index 000000000..5a1b492f0 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/model-selection-projection.ts @@ -0,0 +1,87 @@ +/** Durable model-selection intent and request-use projection. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { SessionEvent } from "@deepseek-ai/dsh-session"; +import type { ProjectionDefinition } from "@deepseek-ai/dsh-session-projection"; +import { z } from "zod"; +import type { + ModelSelection, + ModelSelectionProjection, + ModelSelectionProjectionState, +} from "./types.ts"; + +const modelSelectionSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) as unknown as z.ZodType; + +const modelSelectionProjectionStateSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + pending: modelSelectionSchema.nullable(), +}) as z.ZodType; + +const modelSelectionProjectionSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + next: modelSelectionSchema.nullable(), +}) as z.ZodType; + +/** + * Advance durable model-selection state by one Session event. + * @param state - selection state before the event. + * @param event - next committed Session event. + * @returns the original or advanced selection state. + */ +function applyModelSelectionProjection( + state: ModelSelectionProjectionState, + event: SessionEvent, +): ModelSelectionProjectionState { + if (event.type === "model/selection") { + return sameSelection(state.pending, event.data) + ? state + : { lastUsed: state.lastUsed, pending: event.data }; + } + if (event.type !== "request/header") return state; + const lastUsed: ModelSelection = { + provider: event.data.header.config.provider, + model: event.data.header.config.model, + ...(event.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: String(event.data.header.config.reasoningEffort) }), + }; + const pending = sameSelection(state.pending, lastUsed) ? null : state.pending; + return sameSelection(state.lastUsed, lastUsed) && pending === state.pending + ? state + : { lastUsed, pending }; +} + +const modelSelectionProjection = { + key: "modelSelection", + stateSchema: modelSelectionProjectionStateSchema, + init: () => ({ lastUsed: null, pending: null }), + apply: applyModelSelectionProjection, + wire: { + viewSchema: modelSelectionProjectionSchema, + view: (state) => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }), + }, + stateVersion: 2, +} satisfies ProjectionDefinition<"modelSelection", ModelSelectionProjectionState>; + +function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.provider === right.provider && + left.model === right.model && + left.reasoningEffort === right.reasoningEffort) + ); +} + +/** + * Register the durable model-selection projection when the registry is present. + * @param ctx - Session Controller context. + */ +export function installModelSelectionProjection(ctx: Context): void { + ctx.sessionProjections.register(modelSelectionProjection); +} diff --git a/apps/web-ui/packages/api/session-controller/src/remote-events.ts b/apps/web-ui/packages/api/session-controller/src/remote-events.ts new file mode 100644 index 000000000..002326f92 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/remote-events.ts @@ -0,0 +1,13 @@ +/** Session Controller events available to a Remote Event assembly. */ +type SessionControllerRemoteEvent = + | "api-session/activity" + | "api-session/added" + | "api-session/error" + | "api-session/removed" + | "api-session/status"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertRemoteEventSelection extends Record {} +} + +export {}; diff --git a/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts b/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts new file mode 100644 index 000000000..48d46395c --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/skill-catalog.ts @@ -0,0 +1,108 @@ +/** Session-addressed, cold-readable skill catalog Remote. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-agent-preset-registry/types"; +import { SessionQueryError } from "@deepseek-ai/dsh-session-query"; +import { isUserInvocable } from "@deepseek-ai/dsh-skill"; +import type { ScopeKey } from "@deepseek-ai/dsh-scope"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { SkillListRequest, SkillListValue } from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the Session-addressed `skills` Remote namespace. */ + sessionSkillCatalog: SessionSkillCatalog; + } +} + +/** Host service backing `ctx.remote.skills` without activating a cold Agent. */ +export class SessionSkillCatalog extends TypertRemoteService { + static inject = ["agents", "sessionQuery", "typert"]; + + /** @param ctx - Host context carrying Session reads and optional skill/preset services. */ + constructor(ctx: Context) { + super(ctx, "sessionSkillCatalog", { namespace: "skills" }); + } + + /** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws RemoteError when the Session cannot be inspected or no registry can serve it. + */ + @Remote + async list(request: SkillListRequest, signal: AbortSignal): Promise { + void signal; + const { sessionId } = request; + let cwd: string | undefined; + let agentPreset: string | undefined; + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId); + if (observation.projections === undefined) { + throw new Error("skill catalog requires a projected Session observation"); + } + cwd = observation.header.cwd; + agentPreset = observation.projections.values.agentPreset ?? undefined; + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === "SESSION_QUERY_SESSION_NOT_FOUND") { + throw new RemoteError("session/not-found", `session "${sessionId}" not found`, { + sessionId, + }); + } + throw new RemoteError( + "gateway/internal", + `session "${sessionId}" could not be inspected: ${String(error)}`, + {}, + ); + } + if (cwd === undefined) { + throw new RemoteError("gateway/internal", `session "${sessionId}" has no project cwd`, {}); + } + + const live = this.ctx.agents.get(sessionId); + const presets = this.ctx.get("agentPresets"); + const scoped = live === undefined ? undefined : presets?.serviceFor(live, "skills"); + const skillRegistry = scoped ?? this.ctx.get("skills"); + if (skillRegistry === undefined) { + throw new RemoteError( + "gateway/internal", + "skill registry is absent: neither this session's agent preset nor the host composition mounts @deepseek-ai/dsh-skill", + {}, + ); + } + + await using lease = live === undefined ? await this.scopeFor(agentPreset) : undefined; + const scope = live ?? lease?.key; + try { + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable); + return { + skills: skills.map((skill) => ({ + name: skill.name, + ...(skill.path === undefined ? {} : { path: skill.path }), + description: skill.description, + ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }), + modelInvocable: skill.invocation.modelInvocable, + })), + }; + } catch (error: unknown) { + throw new RemoteError("gateway/internal", `skill listing failed: ${String(error)}`, {}); + } + } + + /** Resolve a live or standing preset scope without creating an Agent. */ + private async scopeFor( + agentPreset: string | undefined, + ): Promise<({ key: ScopeKey } & AsyncDisposable) | undefined> { + const presets = this.ctx.get("agentPresets"); + if (presets === undefined) return undefined; + try { + return await presets.acquireScope(agentPreset); + } catch { + // An unknown or unusable recorded preset falls back to the global registry. + return undefined; + } + } +} + +export default SessionSkillCatalog; diff --git a/apps/web-ui/packages/api/session-controller/src/types.ts b/apps/web-ui/packages/api/session-controller/src/types.ts new file mode 100644 index 000000000..31fcb0343 --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/src/types.ts @@ -0,0 +1,634 @@ +/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ +import type { NativeFileApplication } from "@deepseek-ai/dsh-native-command/types"; + +import type { + AttachmentIdType, + ImageAttachmentLimits, + ImageAttachmentRef, + ImageMediaType, +} from "@deepseek-ai/dsh-attachment"; +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { LlmAttemptId, MessageId } from "@deepseek-ai/dsh-llm/brand"; +import type { TextBlock } from "@deepseek-ai/dsh-llm"; +import type { SessionId, SessionSeqCursor } from "@deepseek-ai/dsh-session/types"; +import type { SessionProjectionMap } from "@deepseek-ai/dsh-session-projection/types"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; + +declare module "@deepseek-ai/dsh-session-projection/types" { + interface SessionProjectionStateMap { + /** Host state persisted for cold Session list summaries. */ + sessionListMetadata: SessionListMetadata; + /** Host state for the boot-constant image-limit view. */ + imageLimits: null; + /** Durable model selection already used by a request and still pending for a later request. */ + modelSelection: ModelSelectionProjectionState; + } + interface SessionProjectionMap { + /** Persisted facts used to summarize a Session without activating it. */ + sessionListMetadata: SessionListMetadata; + /** Image-intake limits enforced by the Session prompt endpoint. */ + imageLimits: ImageAttachmentLimits; + /** Durable model selection already used and selected for the next request. */ + modelSelection: ModelSelectionProjection; + } +} + +declare module "@deepseek-ai/dsh-session/types" { + interface SessionEventMap { + /** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ + "model/selection": ModelSelection; + } +} + +/** Persisted hints used to summarize a cold Session. */ +export interface SessionListMetadata { + /** Whether the folded prefix contains no turn. */ + readonly blank: boolean; + /** Latest human-authored prompt time in the folded prefix. */ + readonly lastPromptAt: number | null; +} + +/** + * Every available wire value a Session-list row carries as partial, possibly + * stale hints. `kind` and `asOfSeq` are independent facts: `kind` says which + * sequence space `asOfSeq` belongs to, and therefore how a client may merge + * the block; `asOfSeq` is the producer's watermark in that space. + */ +export interface SessionProjectionHints { + /** + * `sequenced`: the Host's live registry produced the block for an attached + * Session, so `asOfSeq` is comparable with baselines and frames of the same + * connection. `cached`: a header-only listing viewed the block from the + * persisted projection cache, so `asOfSeq` is the stored record's own + * watermark and must not be compared with the connected Session's values. + */ + readonly kind: "cached" | "sequenced"; + /** Watermark of the block in the sequence space named by `kind`. */ + readonly asOfSeq: number; + /** Provider-validated values present in the block; omitted keys remain unknown. */ + readonly values: SessionProjectionValues; +} + +/** Complete projection values at an exact Session event cursor. */ +export interface SessionProjectionBaseline { + readonly asOfSeq: number; + /** Provider-validated values; omitted keys are absent capabilities at this cut. */ + readonly values: SessionProjectionValues; +} + +/** Typed known projections plus JSON-safe values contributed outside this compilation face. */ +export type SessionProjectionValues = Partial & + Readonly>; + +/** + * Browser-submitted prompt content; the Host promotes image bytes to durable + * references. File parts carry the opaque receipt returned by a preceding + * `uploadFile` call on the same Session. + */ +export type PromptContentPart = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "image"; + readonly mediaType: ImageMediaType; + readonly data: string; + readonly name?: string; + } + | { readonly type: "file"; readonly receiptId: Branded<"file-upload-receipt-id"> }; + +/** Complete model selection for one Session. */ +export interface ModelSelection { + readonly provider: string; + readonly model: string; + readonly reasoningEffort?: string; +} + +/** Host fold state for durable model selection. */ +export interface ModelSelectionProjectionState { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null; + /** Later user selection not yet consumed by a matching model request. */ + readonly pending: ModelSelection | null; +} + +/** Client view of the durable model-selection fold. */ +export interface ModelSelectionProjection { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null; + /** Selection the next request should use, falling back to {@link lastUsed}. */ + readonly next: ModelSelection | null; +} + +/** One adapter-owned reasoning effort for an exact model route. */ +export interface ModelReasoningEffort { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +/** Selectable reasoning metadata for one exact model route. */ +export interface ModelReasoning { + readonly efforts: readonly ModelReasoningEffort[]; + readonly defaultEffort?: string; +} + +/** One model displayed inside its provider group. */ +export interface ModelCatalogModel { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly reasoning?: ModelReasoning; +} + +/** One provider and its successfully loaded model catalog. */ +export interface ModelProviderGroup { + readonly id: string; + readonly name: string; + readonly models: readonly ModelCatalogModel[]; +} + +/** One provider whose model catalog lookup failed. */ +export interface ModelCatalogFailure { + readonly id: string; + readonly name: string; + readonly message: string; +} + +/** Host-generation model catalog and the default used by unconfigured Sessions. */ +export interface ModelCatalog { + readonly default: ModelSelection; + /** Provider routes with at least one currently available catalog model. */ + readonly routableProviders: readonly string[]; + readonly groups: readonly ModelProviderGroup[]; + readonly failures: readonly ModelCatalogFailure[]; +} + +/** One client-requested mutation of a still-pending queue item. */ +export type QueueAction = + | { + readonly kind: "edit"; + /** Non-empty text-only replacement content. */ + readonly content: readonly TextBlock[]; + } + | { readonly kind: "remove" } + | { readonly kind: "steer" }; + +/** One Session list entry. */ +export interface SessionSummary { + /** Whether this Session currently owns a live Agent. */ + readonly agentAvailable: boolean; + readonly sessionId: SessionId; + readonly updatedAt: number; + readonly running: boolean; + readonly blank: boolean; + readonly parentSessionId?: SessionId; + readonly origin?: "subagent"; + readonly cwd?: string; + readonly projections?: SessionProjectionHints; +} + +/** One session-content search result. */ +export interface SessionSearchItem { + readonly sessionId: SessionId; + readonly snippet: string; +} + +/** Maximum number of Sessions returned by one search. */ +export const SESSION_SEARCH_RESULT_LIMIT = 20; + +/** Maximum search snippet length in Unicode code points. */ +export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + "session/provider-credentials-unavailable": Record; + "session/provider-models-unavailable": { readonly provider: string }; + "session/model-unavailable": { readonly provider: string; readonly model: string }; + "session/conflict": { + readonly sessionId: SessionId; + readonly requestedCwd: string; + readonly existingCwd?: string; + }; + "session/projections-unavailable": Record; + "session/writer-held": { readonly sessionId: SessionId }; + "session/agent-busy": { readonly reason: string }; + "session/invalid-time-zone": { readonly value: string }; + "session/workspace-attach-failed": { + readonly sessionId: SessionId; + readonly workspaceId: string; + }; + "agent-preset/conflict": { + readonly sessionId: SessionId; + readonly requestedPreset: string; + readonly existingPreset?: string; + }; + "session/attachment-invalid": { readonly reason: string }; + "session/queue-item-not-found": { readonly itemId: MessageId }; + "session/steer-unavailable": { readonly itemId: MessageId }; + "session/title-invalid": { readonly sessionId: SessionId }; + "session/fork-unavailable": { readonly sessionId: SessionId }; + "subagent/not-found": { + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + }; + "subagent/catalog-diagnostic": { + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + readonly reason: "corrupt" | "unsupported" | "unavailable"; + }; + } +} + +/** Session-addressed request for the human-invocable skill catalog. */ +export interface SkillListRequest { + readonly sessionId: SessionId; +} + +/** One skill available to the Session's human-facing composer. */ +export interface SkillEntry { + /** Absolute SKILL.md path when supplied by a filesystem provider. */ + readonly path?: string; + /** Kebab-case identifier referenced as `/name`. */ + readonly name: string; + /** Short routing description. */ + readonly description: string; + /** Optional extra routing guidance. */ + readonly whenToUse?: string; + /** Whether the same skill is also advertised to the model. */ + readonly modelInvocable: boolean; +} + +/** Human-invocable skills visible through one Session's composition. */ +export interface SkillListValue { + readonly skills: readonly SkillEntry[]; +} + +/** Session list request. */ +export interface SessionListRequest { + readonly cursor?: string; +} + +/** Session list response value. */ +export interface SessionListValue { + readonly items: readonly SessionSummary[]; +} + +/** Session search request. */ +export interface SessionSearchRequest { + readonly query: string; +} + +/** Session search response value. */ +export interface SessionSearchValue { + readonly items: readonly SessionSearchItem[]; + readonly hasMore: boolean; +} + +/** Session creation or explicit-id adoption request. */ +export interface SessionCreateRequest { + readonly workspaceId?: WorkspaceId; + readonly cwd?: string; + readonly sessionId?: SessionId; + readonly agentPreset?: string; +} + +/** Session creation response value. */ +export interface SessionCreateValue { + readonly sessionId: SessionId; + readonly agentPreset?: string; +} + +/** Session model-selection request. */ +export interface SessionSelectModelRequest extends ModelSelection { + readonly sessionId: SessionId; +} + +/** Accepted model selection after Host resolution. */ +export interface SessionSelectModelValue { + readonly selected: ModelSelection; +} + +/** Session rename request. */ +export interface SessionRenameRequest { + readonly sessionId: SessionId; + readonly title: string; +} + +/** Normalized title and the durable event position that committed it. */ +export interface SessionRenameValue { + readonly title: string; + readonly seq: number; +} + +/** Session fork request. */ +export interface SessionForkRequest { + readonly sessionId: SessionId; + /** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */ + readonly atSeq?: number; +} + +/** Identity of a newly forked Session. */ +export interface SessionForkValue { + readonly sessionId: SessionId; +} + +/** Session prompt request. */ +export interface SessionPromptRequest { + /** Client-minted identity persisted on the exact accepted user message. */ + readonly requestId: SessionRequestId; + readonly sessionId: SessionId; + readonly mode: "queue" | "steer"; + /** At least one non-whitespace text part or attachment. */ + readonly content: readonly PromptContentPart[]; + readonly clientTimeZone?: string; +} + +/** Receipt after one prompt enters the target Agent inbox. */ +export interface SessionPromptValue { + readonly accepted: true; +} + +/** Durable image read request. */ +export interface SessionAttachmentRequest { + readonly sessionId: SessionId; + readonly attachmentId: AttachmentIdType; +} + +/** Durable image read response value. */ +export interface SessionAttachmentValue { + readonly attachment: ImageAttachmentRef; + readonly data: string; +} + +/** Pending queue mutation request. */ +export interface SessionUpdateQueueRequest { + readonly sessionId: SessionId; + readonly itemId: MessageId; + readonly action: QueueAction; +} + +/** Receipt after one pending queue mutation commits. */ +export interface SessionUpdateQueueValue { + readonly accepted: true; +} + +/** Active-turn cancellation request. */ +export interface SessionCancelRequest { + readonly sessionId: SessionId; +} + +/** Receipt after cancellation is admitted to the live Agent. */ +export interface SessionCancelValue { + readonly accepted: true; +} + +/** Request to open one path prepared by a Session-aware caller on the Host desktop. */ +export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: "reveal"; + /** Registered application identifier; ignored for reveal. Omission preserves the operating system default. */ + readonly application?: string; + /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ + readonly path: string; +} + +/** Confirmation that the Host handed a workspace path to its native opener. */ +export interface SessionOpenWorkspacePathValue { + readonly opened: true; +} + +/** Client-minted prompt identity used to reconcile optimistic and durable messages. */ +export type SessionRequestId = Branded<"session-request-id">; + +declare module "@deepseek-ai/dsh-llm" { + interface MessageSourceMap { + /** Browser prompt correlation and optional Host-validated time zone. */ + "user-rpc": { kind: "user"; rpcId: SessionRequestId; clientTimeZone?: string }; + } +} + +/** Durable identity selecting an ordinary Session or one direct subagent child. */ +export type SessionAddress = + | { readonly kind: "session"; readonly sessionId: SessionId } + | { + readonly kind: "subagent"; + readonly parentSessionId: SessionId; + readonly childSessionId: SessionId; + readonly mode: "one-shot" | "continuable" | "unknown"; + }; + +/** One non-activating Session projection read. */ +export interface SessionProjectionsRequest { + readonly sessionId: SessionId; +} + +/** Complete Session projection baseline; null when the Session does not exist. */ +export type SessionProjectionsValue = SessionProjectionBaseline | null; + +/** One raw Session event in the Remote journal. */ +export interface SessionEventEntry { + readonly type: "event"; + readonly event: SessionWireEvent; +} + +/** Current logical Session metadata carried on the browser wire. */ +export interface SessionWireHeader { + readonly version: number; + readonly id: SessionId; + readonly createdAt: number; + readonly cwd?: string; + readonly parentSession?: SessionId; + /** Whether the Session contains a fork-inherited prefix. */ + readonly isSeeded: boolean; + readonly origin?: "subagent"; + readonly delegationDepth?: number; + readonly agentPreset?: string; +} + +/** Browser wire surface operation; replacement endpoints are earlier event seqs in surface order. */ +export type SessionWireSurfaceOp = + "append" | { readonly op: "replace"; readonly startSeq: number; readonly endSeq: number }; + +/** One history-page record with compact Assistant streams embedded inside events. */ +export type SessionHistoryRecord = SessionEventEntry; + +/** + * Exact Session event envelope accepted by the Client journal adapter. + * Surface events require surfaceOp; only non-Assistant surface events may cite earlier sources. + * Durable readers own recognition of merge-extensible event names. + */ +export interface SessionWireEvent { + readonly type: string; + readonly seq: number; + readonly time: number; + readonly data: JsonValue; + readonly ignorable?: true; + /** Earlier sources on current surface events; opaque JSON on unknown ignorable events. */ + readonly sourceEventSeqs?: JsonValue; + /** Canonical placement on current surface events; opaque JSON on unknown ignorable events. */ + readonly surfaceOp?: JsonValue; +} + +/** One message-aligned backwards-history request. */ +export interface SessionPageRequest { + readonly address: SessionAddress; + /** Inclusive log cut obtained from the corresponding follow opening frame. */ + readonly throughSeq: number; + readonly beforeSeq?: number; + readonly maxMessages?: number; + /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ + readonly turnWindow?: { + /** Minimum append-origin user/assistant messages; must not exceed maxMessages. */ + readonly minMessages: number; + /** Minimum Turn starts crossed, including the partial Turn at beforeSeq. */ + readonly minTurns: number; + }; +} + +/** One live event request for a durable Session address. */ +export interface SessionFollowRequest extends Pick< + SessionPageRequest, + "maxMessages" | "turnWindow" +> { + readonly address: SessionAddress; + /** Include process-local assistant presentation frames for the Web client. */ + readonly assistantStream?: true; +} + +/** One active assistant attempt in a reconnect opening snapshot. */ +export interface SessionAssistantStreamAttempt { + readonly attemptId: LlmAttemptId; + /** Last durable Session seq observed when this attempt started. */ + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + /** Dense position expected for the next live chunk frame. */ + readonly nextIndex: number; + /** Compact detached stream accumulated at this opening revision. */ + readonly stream: readonly JsonValue[]; +} + +/** Complete process-local assistant state at one follow opening. */ +export interface SessionAssistantStreamBaseline { + readonly revision: number; + readonly activeAttempt?: SessionAssistantStreamAttempt; +} + +/** Browser wire form of one process-local assistant frame. */ +export type SessionAssistantStreamFrame = + | { + readonly type: "start"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + readonly startedAfterSeq: SessionSeqCursor; + readonly turn: number; + readonly step: number; + } + | { + readonly type: "chunk"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + readonly index: number; + readonly time: number; + readonly chunk: JsonValue; + } + | { + readonly type: "end"; + readonly attemptId: LlmAttemptId; + readonly revision: number; + /** Number of chunk frames represented by this terminal marker. */ + readonly index: number; + readonly outcome: + | { + readonly kind: "committed"; + readonly eventType: "assistant/message" | "assistant/attempt"; + readonly seq: number; + } + | { readonly kind: "abandoned" }; + }; + +/** One contiguous backwards page of a Session log. */ +export interface SessionPage { + readonly records: readonly SessionHistoryRecord[]; + readonly hasMore: boolean; +} + +/** Complete opening window followed by ordered durable events and opted-in assistant frames. */ +export type SessionFollowFrame = + | { + readonly type: "snapshot"; + readonly header: SessionWireHeader; + readonly cursor: number; + readonly records: readonly SessionHistoryRecord[]; + readonly hasMore: boolean; + readonly projections: SessionProjectionBaseline; + readonly assistantStream?: SessionAssistantStreamBaseline; + } + | SessionEventEntry + | { readonly type: "assistant-stream"; readonly frame: SessionAssistantStreamFrame }; + +/** Complete live control baseline emitted once per control stream generation. */ +export interface SessionControlBaseline { + readonly projections: Readonly>; +} + +/** One finished projection value and its durable watermark. */ +export interface SessionProjectionUpdate { + readonly sessionId: SessionId; + readonly key: string; + readonly value: JsonValue; + readonly seq: number; +} + +/** Host-wide live state stream. Each generation starts with exactly one baseline. */ +export type SessionControlFrame = + | { readonly type: "baseline"; readonly value: SessionControlBaseline } + | ({ readonly type: "projection" } & SessionProjectionUpdate); + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * A Session became visible or its Agent was created or disposed. + * Consumers upsert the summary and replace its current running and availability state. + * @mode emit + * @param summary - current list row for the Session. + */ + "api-session/added"(summary: SessionSummary): void; + /** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ + "api-session/removed"(sessionId: SessionId): void; + /** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ + "api-session/status"(sessionId: SessionId, running: boolean): void; + /** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ + "api-session/activity"(sessionId: SessionId, updatedAt: number): void; + /** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ + "api-session/error"(sessionId: SessionId, message: string): void; + } +} + +/** JSON-compatible projection value accepted by list consumers. */ +export type SessionProjectionValue = JsonValue; + +/** Application metadata returned by the serving desktop for one file. */ +export type SessionWorkspacePathApplication = NativeFileApplication; diff --git a/apps/web-ui/packages/api/session-controller/tsdown.config.ts b/apps/web-ui/packages/api/session-controller/tsdown.config.ts new file mode 100644 index 000000000..a3694b27e --- /dev/null +++ b/apps/web-ui/packages/api/session-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-session-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts b/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts new file mode 100644 index 000000000..3824f2cae --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/generated/remote.d.ts @@ -0,0 +1,42 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { SettingsDocumentOpenValue } from '@deepseek-ai/dsh-api-settings-controller/types' +import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' +import type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-settings/types' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$63726564656e7469616c73 { + describe: (refs: string[]) => Promise>> + set: (ref: string, value: string) => Promise> + unset: (ref: string) => Promise> + } + interface TypertRemoteNamespace$73657474696e6773 { + describe: () => Promise> + mutate: (ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined) => Promise> + openSettingsDocument: (signal?: AbortSignal) => Promise> + replace: (ns: string, section: Record, expectedRevision: number | undefined) => Promise> + update: (ns: string, patch: Record, expectedRevision: number | undefined) => Promise> + } + interface TypertRemoteMap { + 'credentials/describe': (refs: string[]) => Promise>> + 'credentials/set': (ref: string, value: string) => Promise> + 'credentials/unset': (ref: string) => Promise> + 'settings/describe': () => Promise> + 'settings/mutate': (ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined) => Promise> + 'settings/openSettingsDocument': (signal?: AbortSignal) => Promise> + 'settings/replace': (ns: string, section: Record, expectedRevision: number | undefined) => Promise> + 'settings/update': (ns: string, patch: Record, expectedRevision: number | undefined) => Promise> + } + interface TypertRemoteNamespaceMap { + 'credentials': TypertRemoteNamespace$63726564656e7469616c73 + 'settings': TypertRemoteNamespace$73657474696e6773 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/settings-controller/generated/remote.js b/apps/web-ui/packages/api/settings-controller/generated/remote.js new file mode 100644 index 000000000..dbe06191c --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/generated/remote.js @@ -0,0 +1,390 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let JsonValueRemoteCodec$schema$value +const JsonValueRemoteCodec$schema = () => (JsonValueRemoteCodec$schema$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))])) +let JsonValueRemoteCodec$schema2$value +const JsonValueRemoteCodec$schema2 = () => (JsonValueRemoteCodec$schema2$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))])) +let JsonValueRemoteCodec$schema3$value +const JsonValueRemoteCodec$schema3 = () => (JsonValueRemoteCodec$schema3$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))])) +let JsonValueRemoteCodec$schema4$value +const JsonValueRemoteCodec$schema4 = () => (JsonValueRemoteCodec$schema4$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))])) +let JsonValueRemoteCodec$schema5$value +const JsonValueRemoteCodec$schema5 = () => (JsonValueRemoteCodec$schema5$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))])) +let JsonValueRemoteCodec$schema6$value +const JsonValueRemoteCodec$schema6 = () => (JsonValueRemoteCodec$schema6$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))])) +let JsonValueRemoteCodec$schema7$value +const JsonValueRemoteCodec$schema7 = () => (JsonValueRemoteCodec$schema7$value ??= z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))])) +let _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema$value ??= z.array(z.string())) +let _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema$value ??= z.record(z.string(), z.object({ + 'configured': z.boolean(), + 'source': z.string().optional(), + 'writable': z.boolean(), +}))) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema$value ??= z.object({ + 'writable': z.boolean(), + 'hasDocument': z.boolean(), + 'namespaces': z.array(z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema7())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema7()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema$value ??= z.array(z.union([z.object({ + 'op': z.literal("set"), + 'path': z.array(z.string()), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema5())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema5()))]), +}), z.object({ + 'op': z.literal("unset"), + 'path': z.array(z.string()), +})]))) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema6())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema6()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema$value ??= z.object({ + 'opened': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema$value ??= z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema3())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema3()))]))) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema4())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema4()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema$value ??= z.record(z.string(), z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema()))]))) +let _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema$value ??= z.union([z.undefined(), z.number()])) +let _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema$value +const _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema = () => (_deepseek_ai_dsh_api_settings_controller_settings_update_result$schema$value ??= z.object({ + 'autoGenerate': z.boolean(), + 'ns': z.string(), + 'schema': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]), + 'value': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]), + 'base': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).optional(), + 'user': z.union([z.literal(null), z.string(), z.number(), z.literal(false), z.literal(true), z.array(z.lazy(() => JsonValueRemoteCodec$schema2())), z.record(z.string(), z.lazy(() => JsonValueRemoteCodec$schema2()))]).optional(), + 'applies': z.literal("live"), + 'secrets': z.array(z.object({ + 'path': z.array(z.string()), + 'set': z.boolean(), +})), + 'revision': z.number(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-settings-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/describe', + service: 'credentialsController', + namespace: 'credentials', + method: 'describe', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'refs', + wire: 'refs', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/describe:refs', + create: _deepseek_ai_dsh_api_settings_controller_credentials_describe_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/describe:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_describe_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":83,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/set', + service: 'credentialsController', + namespace: 'credentials', + method: 'set', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ref', + wire: 'ref', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:ref', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_0$schema, + }, + }, + { + name: 'value', + wire: 'value', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:value', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/set:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_set_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":100,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#credentials/unset', + service: 'credentialsController', + namespace: 'credentials', + method: 'unset', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ref', + wire: 'ref', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/unset:ref', + create: _deepseek_ai_dsh_api_settings_controller_credentials_unset_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#credentials/unset:result', + create: _deepseek_ai_dsh_api_settings_controller_credentials_unset_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/credentials.ts","line":113,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/describe', + service: 'settingsController', + namespace: 'settings', + method: 'describe', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsDescribeValue', + create: _deepseek_ai_dsh_api_settings_controller_settings_describe_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":98,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/mutate', + service: 'settingsController', + namespace: 'settings', + method: 'mutate', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_0$schema, + }, + }, + { + name: 'ops', + wire: 'ops', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:ops', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/mutate:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_mutate_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":152,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/openSettingsDocument', + service: 'settingsController', + namespace: 'settings', + method: 'openSettingsDocument', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller/types#SettingsDocumentOpenValue', + create: _deepseek_ai_dsh_api_settings_controller_settings_openSettingsDocument_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":167,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/replace', + service: 'settingsController', + namespace: 'settings', + method: 'replace', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_0$schema, + }, + }, + { + name: 'section', + wire: 'section', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:section', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/replace:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_replace_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":133,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-settings-controller#settings/update', + service: 'settingsController', + namespace: 'settings', + method: 'update', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'ns', + wire: 'ns', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:ns', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_0$schema, + }, + }, + { + name: 'patch', + wire: 'patch', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:patch', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_1$schema, + }, + }, + { + name: 'expectedRevision', + wire: 'expectedRevision', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-settings-controller#settings/update:expectedRevision', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-settings/types#SettingsNamespaceView', + create: _deepseek_ai_dsh_api_settings_controller_settings_update_result$schema, + }, + sourceLocation: {"file":"packages/api/settings-controller/src/index.ts","line":116,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/settings-controller/package.json b/apps/web-ui/packages/api/settings-controller/package.json new file mode 100644 index 000000000..f1b150fa7 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-api-settings-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/api/settings-controller/src/credentials.ts b/apps/web-ui/packages/api/settings-controller/src/credentials.ts new file mode 100644 index 000000000..fb9adbf5e --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/credentials.ts @@ -0,0 +1,161 @@ +/** + * Host owner of the `credentials` Remote namespace: the reference half of + * `ctx.credentials` as a browser configuration page reads and writes it. + * + * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts + */ + +import { Context } from "@deepseek-ai/cordis"; +import { credentialRef } from "@deepseek-ai/dsh-credentials"; +import type { CredentialProvider } from "@deepseek-ai/dsh-credentials"; +import type { CredentialInfo } from "@deepseek-ai/dsh-credentials/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { z } from "zod"; + +/** + * Fan-out bound on one remote `describe` batch. A settings page asks about the + * references its own rows name, so this is far above any real page and still + * keeps one authenticated request from starting unbounded provider work. + */ +const MAX_DESCRIBE_REFS = 64; + +const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); +const describeRequestSchema = z.object({ + refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS), +}); +const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) }); +const unsetRequestSchema = z.object({ ref: credentialRefSchema }); + +/** Parse the domain constraints that are more specific than generated TypeScript codecs. */ +function parseRequest(method: string, schema: z.ZodType, value: unknown): T { + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new RemoteError("gateway/bad-request", `invalid payload for ${method}`, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +/** + * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns + * a business result without decoding it, so a provider whose `describe` carried + * extra enumerable properties would otherwise serialize them to the caller. + * @param info - the provider's answer for one reference. + * @returns the same facts with nothing else attached. + */ +function projectCredentialInfo(info: CredentialInfo): CredentialInfo { + return { + configured: info.configured, + ...(info.source === undefined ? {} : { source: info.source }), + writable: info.writable, + }; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `credentials` Remote namespace. */ + credentialsController: CredentialsController; + } +} + +/** + * Host service backing the generated `ctx.remote.credentials` namespace. It + * carries every wire obligation the credential seam itself does not: the batch + * fan-out bound, the field-by-field view projection, the reference-grammar + * guard, and the refusal mapping. Secret values cross in one direction only — + * no method here returns one. + */ +export class CredentialsController extends TypertRemoteService { + /** @param ctx - Host context where a credential provider may be mounted. */ + constructor(ctx: Context) { + super(ctx, "credentialsController", { namespace: "credentials" }); + } + + /** + * Describe several references for one configuration surface. Batched because + * a settings page describes every reference its rows name at once, and one + * round trip keeps those rows from settling separately. + * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar + * rejects the whole call as `gateway/bad-request`. + * @returns one view per requested name, keyed by that name. + * @throws RemoteError when the request is invalid or no credential provider is mounted. + */ + @Remote + async describe(refs: string[]): Promise> { + const request = parseRequest("credentials.describe", describeRequestSchema, { refs }); + const branded = request.refs.map((ref) => [ref, credentialRef(ref)] as const); + const credentials = this.provider(); + const entries = await Promise.all( + branded.map( + async ([ref, key]) => + [ref, projectCredentialInfo(await credentials.describe(key))] as const, + ), + ); + return Object.fromEntries(entries); + } + + /** + * Store one value from a configuration surface. The value crosses the wire in + * this direction only: no read path returns it. + * @param ref - reference name to store under. + * @param value - the non-empty secret value. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async set(ref: string, value: string): Promise { + const request = parseRequest("credentials.set", setRequestSchema, { ref, value }); + const branded = credentialRef(request.ref); + const credentials = this.provider(); + await this.write(request.ref, () => credentials.set(branded, request.value)); + } + + /** + * Remove one reference from a configuration surface. + * @param ref - reference name to remove. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async unset(ref: string): Promise { + const request = parseRequest("credentials.unset", unsetRequestSchema, { ref }); + const branded = credentialRef(request.ref); + const credentials = this.provider(); + await this.write(request.ref, () => credentials.unset(branded)); + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): CredentialProvider { + const credentials = this.ctx.get("credentials"); + if (credentials === undefined) { + throw new RemoteError( + "gateway/internal", + "credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition", + {}, + ); + } + return credentials; + } + + /** + * Run one remote write and report every refusal as `credential/rejected` + * carrying the seam's own message: a read-only source shadowing the reference + * is what a configuration surface must show verbatim. Callers brand the + * reference before entering, so a name outside the grammar never reaches this + * path and fails the same way it does on the read side. The details name only + * the reference, so no failure path can carry the value back out. + */ + private async write(ref: string, write: () => Promise): Promise { + try { + await write(); + } catch (error: unknown) { + throw new RemoteError( + "credential/rejected", + error instanceof Error ? error.message : String(error), + { ref }, + { cause: error }, + ); + } + } +} + +export default CredentialsController; diff --git a/apps/web-ui/packages/api/settings-controller/src/index.ts b/apps/web-ui/packages/api/settings-controller/src/index.ts new file mode 100644 index 000000000..a46a096c1 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/index.ts @@ -0,0 +1,298 @@ +/** + * Host Remote owner for the configuration surfaces over the settings-domain + * seams. Two namespaces: `settings`, the redacted reads and writes of + * `ctx.settings`, owned by the class below; and `credentials`, mounted from + * here as its own plugin. + * + * @module @deepseek-ai/dsh-api-settings-controller + */ + +import { Context } from "@deepseek-ai/cordis"; +import { openNativeTextFile } from "@deepseek-ai/dsh-native-command"; +import type { SettingsDescriptor, SettingsPathOp, SettingsForms } from "@deepseek-ai/dsh-settings"; +import type { + SettingsDescribeValue, + SettingsNamespaceView, + SettingsPathOpView, +} from "@deepseek-ai/dsh-settings/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { JsonValue } from "@deepseek-ai/dsh-util-values"; +import { z } from "zod"; +import { CredentialsController } from "./credentials.ts"; +import type { SettingsDocumentOpenValue } from "./types.ts"; + +export { CredentialsController } from "./credentials.ts"; +export type * from "./types.ts"; + +const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }); + +/** Read abort state afresh after an awaited provider or opener call. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + /** Host text-editor integration used to open the settings document. */ + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise; +} + +/** + * Project one redacted descriptor onto its wire view, field by field. The + * Gateway returns a business result without decoding it, so a provider whose + * descriptor carried extra enumerable properties would otherwise serialize them + * to the caller. + * @param descriptor - one descriptor read under `redactSecrets`. + * @returns the same facts with nothing else attached. + */ +function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView { + return { + ns: String(descriptor.ns), + autoGenerate: descriptor.autoGenerate, + schema: descriptor.schema as JsonValue, + value: descriptor.value as JsonValue, + ...(descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue }), + ...(descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue }), + applies: descriptor.applies, + secrets: (descriptor.secrets ?? []).map((secret) => ({ + path: [...secret.path], + set: secret.set, + })), + revision: descriptor.revision, + }; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `settings` Remote namespace. */ + settingsController: SettingsController; + } +} + +/** + * Host service backing the generated `ctx.remote.settings` namespace. Every + * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot + * ride a response. Writes expose the settings service's merge, replacement, + * and path-addressed operations, and classify every provider refusal as + * `settings/conflict` or `settings/rejected` with the service's message. + */ +export class SettingsController extends TypertRemoteService { + private readonly openTextFile: (path: string, signal: AbortSignal) => Promise; + + /** + * Register the settings namespace and mount the credentials namespace beside + * it. Both namespaces stay registered when a provider is absent so calls can + * return the configuration API's actionable missing-provider diagnostic. + * @param ctx - Host context where settings and credential providers may be mounted. + */ + constructor(ctx: Context, internals: SettingsControllerInternals = {}) { + super(ctx, "settingsController", { namespace: "settings" }); + this.openTextFile = internals.openTextFile ?? openNativeTextFile; + ctx.plugin(CredentialsController); + } + + /** + * Describe every registered namespace for a configuration page: redacted + * layered values plus the serialized schema the page renders its form from. + * @returns provider writability, local-document presence, and one view per namespace. + * @throws RemoteError when no settings provider is mounted. + */ + @Remote + describe(): SettingsDescribeValue { + const settings = this.provider(); + return { + writable: settings.writable, + hasDocument: true, + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), + }; + } + + /** + * Merge a patch into one namespace's stored user section. + * @param ns - namespace key to write. + * @param patch - fields to merge into the user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + update( + ns: string, + patch: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "update", patch, expectedRevision); + } + + /** + * Replace one namespace's stored user section wholesale. + * @param ns - namespace key to write. + * @param section - complete replacement user section. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + replace( + ns: string, + section: Record, + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "replace", section, expectedRevision); + } + + /** + * Apply path-addressed edits to one namespace's user section, resolved against + * the section as stored rather than against whatever the caller last read, + * then answer with that namespace's new redacted view. + * @param ns - namespace key to write. + * @param ops - the edits to apply, in order. + * @param expectedRevision - revision the caller read; `undefined` writes unconditionally. + * @returns the namespace's redacted view after the write. + * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. + */ + @Remote + async mutate( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + return this.write(ns, "mutate", ops, expectedRevision); + } + + /** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws RemoteError when no document exists, preparation fails, or opening fails. + */ + @Remote + async openSettingsDocument(signal: AbortSignal): Promise { + const settings = this.provider(); + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + let path: string; + try { + path = await settings.prepareDocument(); + } catch (error: unknown) { + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document preparation was aborted", {}); + throw new RemoteError( + "gateway/internal", + `settings document preparation failed: ${messageOf(error)}`, + {}, + { cause: error }, + ); + } + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + try { + await this.openTextFile(path, signal); + return { opened: true }; + } catch (error: unknown) { + if (isAborted(signal)) + throw new RemoteError("gateway/cancelled", "settings document open was aborted", {}); + throw new RemoteError( + "gateway/internal", + `path open failed: ${messageOf(error)}`, + {}, + { cause: error }, + ); + } + } + + private async write( + ns: string, + mode: "update" | "replace" | "mutate", + input: Record | SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise { + const parsed = settingsNamespaceRequestSchema.safeParse({ ns }); + if (!parsed.success) { + throw new RemoteError("gateway/bad-request", `invalid payload for settings.${mode}`, { + issues: parsed.error.issues, + }); + } + const settings = this.provider(); + const namespace = parsed.data.ns; + try { + if (mode === "update") await settings.update(namespace, input, expectedRevision); + else if (mode === "replace") await settings.replace(namespace, input, expectedRevision); + else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision); + } catch (error: unknown) { + throw rejected(ns, error); + } + const descriptor = settings + .describe({ redactSecrets: true }) + .find((candidate) => candidate.ns === namespace); + if (descriptor === undefined) { + // The write committed but the namespace vanished before this read: only a + // concurrent registrant disposal can produce it. + throw new RemoteError( + "gateway/internal", + `settings namespace "${ns}" was disposed after the ${mode}`, + {}, + ); + } + return namespaceView(descriptor); + } + + /** Resolve the optional provider or report how to supply it. */ + private provider(): SettingsForms { + const settings = this.ctx.get("settings"); + if (settings === undefined) { + throw new RemoteError( + "gateway/internal", + "settings service is absent: mount @deepseek-ai/dsh-settings with @deepseek-ai/dsh-config-editor in the profile composition", + {}, + ); + } + return settings; + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface SettingsConflict { + readonly code: "SETTINGS_CONFLICT"; + readonly message: string; + readonly expected: number; + readonly actual: number; +} + +function settingsConflictOf(error: unknown): SettingsConflict | undefined { + if (typeof error !== "object" || error === null) return undefined; + if ( + Reflect.get(error, "code") !== "SETTINGS_CONFLICT" || + typeof Reflect.get(error, "message") !== "string" || + typeof Reflect.get(error, "expected") !== "number" || + typeof Reflect.get(error, "actual") !== "number" + ) + return undefined; + return error as SettingsConflict; +} + +/** + * Classify one seam refusal. A stale writer is its own outcome, not a malformed + * request: the client must re-read and re-apply rather than treat the write as + * invalid. + * @param ns - the namespace the write addressed. + * @param error - whatever the seam threw. + * @returns the failure to raise for that refusal. + */ +function rejected(ns: string, error: unknown): RemoteError { + const conflict = settingsConflictOf(error); + if (conflict !== undefined) { + return new RemoteError( + "settings/conflict", + conflict.message, + { ns, expected: conflict.expected, actual: conflict.actual }, + { cause: error }, + ); + } + return new RemoteError("settings/rejected", messageOf(error), { ns }, { cause: error }); +} + +export default SettingsController; diff --git a/apps/web-ui/packages/api/settings-controller/src/types.ts b/apps/web-ui/packages/api/settings-controller/src/types.ts new file mode 100644 index 000000000..56ef03e19 --- /dev/null +++ b/apps/web-ui/packages/api/settings-controller/src/types.ts @@ -0,0 +1,38 @@ +/** + * Browser-safe failure vocabulary of the configuration surfaces this package + * serves. The redacted views themselves live with their seam in + * `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already + * register that file for the Client compilation face. + * + * @module @deepseek-ai/dsh-api-settings-controller/types + */ + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** + * Every seam refusal that is not a stale write: an unregistered or malformed + * namespace, a read-only provider, schema validation, storage. + */ + "settings/rejected": { readonly ns: string }; + /** + * The stored revision moved after the caller read it. Its own outcome rather + * than an invalid request: the caller must re-read and re-apply. + */ + "settings/conflict": { + readonly ns: string; + readonly expected: number; + readonly actual: number; + }; + /** + * The provider refused a valid credential write, for example because a + * read-only source shadows the reference. The details name only the + * reference, never the value. + */ + "credential/rejected": { readonly ref: string }; + } +} + +/** Confirmation that the settings document was handed to the native editor. */ +export interface SettingsDocumentOpenValue { + readonly opened: true; +} diff --git a/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts b/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts new file mode 100644 index 000000000..829c46a71 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/generated/remote.d.ts @@ -0,0 +1,52 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame, TerminalShell, WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$7465726d696e616c { + close: (agentId: SessionId, id: WebTerminalId) => Promise> + create: (agentId: SessionId, request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + environment: (agentId: SessionId, signal?: AbortSignal) => Promise> + follow: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + list: (sessionId: SessionId) => Promise> + rename: (agentId: SessionId, id: WebTerminalId, title: string) => Promise> + resize: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + retain: (sessionId: SessionId, id: WebTerminalId, signal?: AbortSignal) => RemoteStreamHandle + shells: (agentId: SessionId, signal?: AbortSignal) => Promise> + write: (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } + interface TypertRemoteMap { + 'terminal/close': (agentId: SessionId, id: WebTerminalId) => Promise> + 'terminal/create': (agentId: SessionId, request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + 'terminal/environment': (agentId: SessionId, signal?: AbortSignal) => Promise> + 'terminal/follow': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + 'terminal/list': (sessionId: SessionId) => Promise> + 'terminal/rename': (agentId: SessionId, id: WebTerminalId, title: string) => Promise> + 'terminal/resize': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + 'terminal/retain': (sessionId: SessionId, id: WebTerminalId, signal?: AbortSignal) => RemoteStreamHandle + 'terminal/shells': (agentId: SessionId, signal?: AbortSignal) => Promise> + 'terminal/write': (agentId: SessionId, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } + interface TypertRemoteNamespaceMap { + 'terminal': TypertRemoteNamespace$7465726d696e616c + } + interface TypertRemoteScopeMap { + 'agent:terminal/close': (id: WebTerminalId) => Promise> + 'agent:terminal/create': (request: TerminalCreateRequest, signal?: AbortSignal) => Promise> + 'agent:terminal/environment': (signal?: AbortSignal) => Promise> + 'agent:terminal/follow': (id: WebTerminalId, attachmentId: TerminalAttachmentId, signal?: AbortSignal) => RemoteStreamHandle + 'agent:terminal/rename': (id: WebTerminalId, title: string) => Promise> + 'agent:terminal/resize': (id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number) => Promise> + 'agent:terminal/shells': (signal?: AbortSignal) => Promise> + 'agent:terminal/write': (id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/terminal-controller/generated/remote.js b/apps/web-ui/packages/api/terminal-controller/generated/remote.js new file mode 100644 index 000000000..9486c277d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/generated/remote.js @@ -0,0 +1,605 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema$value ??= z.object({ + 'shellPath': z.string().readonly().optional(), + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema$value ??= z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema$value ??= z.object({ + 'cwd': z.string().readonly(), + 'maxInputBytes': z.number().readonly(), + 'maxCols': z.number().readonly(), + 'maxRows': z.number().readonly(), + 'scrollback': z.number().readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("snapshot").readonly(), + 'sequence': z.number().readonly(), + 'screen': z.string().readonly(), + 'info': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}).readonly(), +}), z.object({ + 'type': z.literal("output").readonly(), + 'sequence': z.number().readonly(), + 'data': z.string().readonly(), +}), z.object({ + 'type': z.literal("state").readonly(), + 'info': z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema$value ??= z.array(z.object({ + 'id': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), + 'shell': z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}).readonly(), + 'cwd': z.string().readonly(), + 'cols': z.number().readonly(), + 'rows': z.number().readonly(), + 'state': z.union([z.literal("failed"), z.literal("running"), z.literal("exited")]).readonly(), + 'exitCode': z.union([z.literal(null), z.number()]).readonly(), + 'error': z.string().readonly().optional(), + 'controllerId': z.intersection(z.string(), z.unknown()).readonly().optional(), +}))) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema$value ??= z.number()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema$value ??= z.number()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema$value ??= z.void()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema$value ??= z.object({ + 'type': z.literal("retained").readonly(), +})) +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema$value ??= z.array(z.object({ + 'path': z.string().readonly(), + 'args': z.array(z.string()).readonly(), + 'name': z.string().readonly(), +}))) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value +const _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema = () => (_deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema$value ??= z.void()) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-terminal-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/close', + service: 'terminalController', + namespace: 'terminal', + method: 'close', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/close:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_close_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":269,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/create', + service: 'terminalController', + namespace: 'terminal', + method: 'create', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_0$schema, + }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalCreateRequest', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalInfo', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_create_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":158,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/environment', + service: 'terminalController', + namespace: 'terminal', + method: 'environment', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_environment_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalEnvironment', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_environment_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":118,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/follow', + service: 'terminalController', + namespace: 'terminal', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalFrame', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":216,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/list', + service: 'terminalController', + namespace: 'terminal', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'sessionId', + wire: 'sessionId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/list:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_list_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":144,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename', + service: 'terminalController', + namespace: 'terminal', + method: 'rename', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_1$schema, + }, + }, + { + name: 'title', + wire: 'title', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename:title', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_parameter_2$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/rename:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":257,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize', + service: 'terminalController', + namespace: 'terminal', + method: 'resize', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_2$schema, + }, + }, + { + name: 'cols', + wire: 'cols', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:cols', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_3$schema, + }, + }, + { + name: 'rows', + wire: 'rows', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:rows', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_parameter_4$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/resize:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_resize_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":245,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/retain', + service: 'terminalController', + namespace: 'terminal', + method: 'retain', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'sessionId', + wire: 'sessionId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalRetentionFrame', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_retain_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":198,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/shells', + service: 'terminalController', + namespace: 'terminal', + method: 'shells', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/shells:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_shells_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":133,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-terminal-controller#terminal/write', + service: 'terminalController', + namespace: 'terminal', + method: 'write', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_0$schema, + }, + }, + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#WebTerminalId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_1$schema, + }, + }, + { + name: 'attachmentId', + wire: 'attachmentId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller/types#TerminalAttachmentId', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_2$schema, + }, + }, + { + name: 'data', + wire: 'data', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/write:data', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_parameter_3$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-terminal-controller#terminal/write:result', + create: _deepseek_ai_dsh_api_terminal_controller_terminal_write_result$schema, + }, + sourceLocation: {"file":"packages/api/terminal-controller/src/index.ts","line":230,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/terminal-controller/package.json b/apps/web-ui/packages/api/terminal-controller/package.json new file mode 100644 index 000000000..4872f4003 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-api-terminal-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts b/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts new file mode 100644 index 000000000..4e303a9e5 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/bindings.ts @@ -0,0 +1,75 @@ +/** Independent content-to-terminal records keep concurrent browser writes disjoint. */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WebTerminalId } from "../types.ts"; + +const PREFIX = "dsh.terminal.binding.v1."; + +/** Saved recovery targets keyed by Session and globally unique terminal content identity. */ +export class TerminalBindings { + private readonly memory = new Map(); + + /** + * Read a saved target, retaining this window's value when storage is unavailable. + * @param sessionId - owning Session. + * @param contentId - terminal content identity, shared only by deliberate copies. + * @returns the existing Host identity, if one has been saved. + */ + get(sessionId: SessionId, contentId: string): WebTerminalId | undefined { + const key = this.key(sessionId, contentId); + const known = this.memory.get(key); + if (known !== undefined) return known; + if (typeof localStorage === "undefined") return undefined; + try { + const raw = localStorage.getItem(key); + if (raw === null) return undefined; + const value: unknown = JSON.parse(raw); + if (typeof value !== "string" || !/^[\w-]{1,128}$/u.test(value)) return undefined; + this.memory.set(key, value as WebTerminalId); + return value as WebTerminalId; + } catch (_storageUnavailable) { + return undefined; + } + } + + /** + * Save an identity before its Host allocation begins. + * @param sessionId - owning Session. + * @param contentId - globally unique terminal content identity. + * @param id - existing or newly allocated Host identity. + */ + set(sessionId: SessionId, contentId: string, id: WebTerminalId): void { + const key = this.key(sessionId, contentId); + this.memory.set(key, id); + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(key, JSON.stringify(id)); + } catch (error) { + console.error("Terminal binding persistence failed:", error); + } + } + + /** + * Remove this content's target after its close intent has been saved. + * @param sessionId - owning Session. + * @param contentId - closing terminal content identity. + */ + delete(sessionId: SessionId, contentId: string): void { + const key = this.key(sessionId, contentId); + this.memory.delete(key); + if (typeof localStorage === "undefined") return; + try { + localStorage.removeItem(key); + } catch (error) { + console.error("Terminal binding cleanup failed:", error); + } + } + + /** Release cached values when the Client service is disposed. */ + clear(): void { + this.memory.clear(); + } + + private key(sessionId: SessionId, contentId: string): string { + return PREFIX + JSON.stringify([sessionId, contentId]); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts b/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts new file mode 100644 index 000000000..5b9487a70 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/close-requests.ts @@ -0,0 +1,89 @@ +/** Unfinished close requests survive reload independently of the removed sidebar tabs. */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WebTerminalId } from "../types.ts"; + +/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */ +export interface TerminalCloseRequest { + readonly sessionId: SessionId; + readonly id: WebTerminalId; + readonly title: string; +} + +const PREFIX = "dsh.terminal.close.v1."; + +/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */ +export class TerminalCloseRequests { + private readonly requests = new Map(); + + constructor() { + try { + if (typeof localStorage === "undefined") return; + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (key?.startsWith(PREFIX)) this.load(key); + } + } catch (error) { + console.error("Terminal cleanup recovery failed:", error); + } + } + + /** + * Read cleanup work still awaiting Host confirmation. + * @returns unfinished requests owned by this browser instance. + */ + pending(): readonly TerminalCloseRequest[] { + return [...this.requests.values()]; + } + + /** + * Retain cleanup across reload before removing a tab. + * @param request - close intent to save before removing its tab. + */ + save(request: TerminalCloseRequest): void { + this.requests.set(request.id, request); + try { + if (typeof localStorage !== "undefined") + localStorage.setItem(PREFIX + request.id, JSON.stringify(request)); + } catch (error) { + console.error("Terminal cleanup persistence failed:", error); + } + } + + /** + * Forget confirmed cleanup in memory and browser storage. + * @param id - terminal whose Host cleanup succeeded. + */ + remove(id: WebTerminalId): void { + this.requests.delete(id); + try { + if (typeof localStorage !== "undefined") localStorage.removeItem(PREFIX + id); + } catch (error) { + console.error("Terminal cleanup persistence failed:", error); + } + } + + private load(key: string): void { + try { + const raw = localStorage.getItem(key); + if (raw === null) return; + const parsed: unknown = JSON.parse(raw); + if (!isRequest(parsed) || key !== PREFIX + parsed.id) + throw new Error("Invalid terminal cleanup request"); + this.requests.set(parsed.id, parsed); + } catch (error) { + console.error("Terminal cleanup recovery failed:", error); + } + } +} + +function isRequest(value: unknown): value is TerminalCloseRequest { + if (typeof value !== "object" || value === null) return false; + const request = value as Record; + return ( + typeof request.sessionId === "string" && + request.sessionId.length > 0 && + typeof request.id === "string" && + /^[\w-]{1,128}$/u.test(request.id) && + typeof request.title === "string" + ); +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/index.ts b/apps/web-ui/packages/api/terminal-controller/src/client/index.ts new file mode 100644 index 000000000..c4aa84466 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/index.ts @@ -0,0 +1,313 @@ +/** Client terminal model service; views are keyed independently from Host terminal identities. */ +import { Service, type Context } from "@deepseek-ai/cordis"; +import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import { TerminalView, type TerminalRemote } from "./model.ts"; +import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import type { TerminalShell, WebTerminalId, WebTerminalInfo } from "../types.ts"; +import { preferredShell, rememberShell } from "./shell-preference.ts"; +import { TerminalCloseRequests, type TerminalCloseRequest } from "./close-requests.ts"; +import { TerminalWindowHold } from "./retention.ts"; +import { TerminalBindings } from "./bindings.ts"; + +export type { + TerminalView, + TerminalViewState, + TerminalViewIssue, + TerminalRenderFrame, + TerminalRemote, +} from "./model.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free browser terminal views and explicit process cleanup. */ + webTerminals: ClientTerminals; + } +} + +/** Host-discovered shell menu with the browser's remembered available choice. */ +export interface TerminalLaunchShells { + readonly shells: readonly TerminalShell[]; + readonly selectedShell: string | undefined; +} + +/** A failed background close that can be retried without restoring its tab. */ +export interface TerminalCloseFailure { + readonly id: WebTerminalId; + readonly title: string; + readonly message: string; +} + +/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */ +export class ClientTerminals extends Service { + /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */ + readonly closeFailures: SnapshotStore = createSnapshotStore< + readonly TerminalCloseFailure[] + >([]); + private readonly requests = new TerminalCloseRequests(); + private readonly closing = new Map>(); + private readonly closed = new Set( + this.requests.pending().map((request) => request.id), + ); + private disposed = false; + private readonly views = new Map>(); + private readonly bindings = new TerminalBindings(); + private readonly holds = new Map>(); + private readonly releasing = new Set>(); + private openTabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[] = []; + + /** + * @param ctx - Client root Context with Gateway and terminal Remote namespace. + * @param remote - generated terminal namespace. + */ + constructor( + ctx: Context, + private readonly remote: TerminalRemote, + ) { + super(ctx, "webTerminals"); + ctx.effect( + () => async () => { + this.disposed = true; + const detaching = [...this.views.values()].flatMap((views) => + [...views.values()].map((view) => view.dispose()), + ); + this.views.clear(); + this.bindings.clear(); + const holds = [...this.holds.values()].flatMap((holds) => + [...holds.values()].map((hold) => hold.dispose()), + ); + this.holds.clear(); + await Promise.all([...detaching, ...holds, ...this.releasing, ...this.closing.values()]); + }, + "terminal-controller.client.views", + ); + for (const request of this.requests.pending()) this.cleanup(request); + } + + /** + * Return the stable model for one sidebar occurrence. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key. + * @param contentId - globally unique content identity; layout-local tab ids are not persistence keys. + * @param terminalId - existing Host identity when restoring a listed terminal; otherwise reuse the saved content identity. + * @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell. + * @returns its observable state and terminal commands. + */ + view( + sessionId: SessionId, + key: string, + contentId: string, + terminalId?: WebTerminalId, + shellPath?: string, + ): TerminalView { + let views = this.views.get(sessionId); + if (views === undefined) { + views = new Map(); + this.views.set(sessionId, views); + } + let view = views.get(key); + if (view === undefined) { + const saved = terminalId ?? this.bindings.get(sessionId, contentId); + const id = saved ?? (randomUUID() as WebTerminalId); + this.bindings.set(sessionId, contentId, id); + view = new TerminalView( + sessionId, + this.remote, + this.ctx.remote, + id, + saved === undefined, + shellPath, + (signal) => this.hold(sessionId, id).ready(signal), + ); + views.set(key, view); + this.reconcileHolds(); + void view.refresh(); + } + return view; + } + + /** + * Discover available launch choices on demand without allocating a PTY. + * @param sessionId - target Session. + * @param signal - the menu request lifetime. + * @returns installed shells and the currently usable browser preference. + */ + async launchShells(sessionId: SessionId, signal: AbortSignal): Promise { + const result = await this.remote.shells(sessionId, signal); + if (!result.ok) throw new Error(result.error.message); + const previous = preferredShell(); + return { + shells: result.value, + selectedShell: + result.value.find((shell) => shell.path === previous)?.path ?? result.value[0]?.path, + }; + } + + /** + * Remember the guide selection before allocating its terminal tab. + * @param path - shell selected from Host discovery. + */ + selectShell(path: string): void { + rememberShell(path); + } + + /** + * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key, including an inactive restored tab. + * @param contentId - globally unique content identity whose binding is removed. + * @param terminalId - restored identity if the tab has no model yet. + */ + close(sessionId: SessionId, key: string, contentId: string, terminalId?: WebTerminalId): void { + const views = this.views.get(sessionId); + const view = views?.get(key); + const id = view?.id ?? terminalId ?? this.bindings.get(sessionId, contentId); + if (id === undefined) return; + const request: TerminalCloseRequest = { + sessionId, + id, + title: view?.state.getSnapshot().title ?? key, + }; + this.closed.add(id); + this.requests.save(request); + this.bindings.delete(sessionId, contentId); + views?.delete(key); + if (views?.size === 0) this.views.delete(sessionId); + this.cleanup(request, view); + this.reconcileHolds(); + } + + /** + * Reconcile this window's open terminal occurrences, including dormant saved Sessions. + * @param tabs - terminal-kind membership supplied by the sidebar layout owner. + */ + retainTabs(tabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[]): void { + this.openTabs = tabs; + this.reconcileHolds(); + } + + private hold(sessionId: SessionId, id: WebTerminalId): TerminalWindowHold { + let holds = this.holds.get(sessionId); + if (holds === undefined) { + holds = new Map(); + this.holds.set(sessionId, holds); + } + let hold = holds.get(id); + if (hold === undefined || hold.failed) { + if (hold !== undefined) this.release(hold); + hold = new TerminalWindowHold(this.ctx.remote, this.remote, sessionId, id); + holds.set(id, hold); + } + return hold; + } + + private reconcileHolds(): void { + if (this.disposed) return; + const wanted = new Map>(); + for (const tab of this.openTabs) { + const id = this.bindings.get(tab.sessionId, tab.contentId); + if (id === undefined || this.closed.has(id)) continue; + let ids = wanted.get(tab.sessionId); + if (ids === undefined) { + ids = new Set(); + wanted.set(tab.sessionId, ids); + } + ids.add(id); + const view = this.views.get(tab.sessionId)?.get(tab.tabId); + if (view === undefined || view.state.getSnapshot().info !== undefined) { + if (!this.holds.get(tab.sessionId)?.has(id)) this.hold(tab.sessionId, id); + } + } + for (const [sessionId, holds] of this.holds) { + for (const [id, hold] of holds) { + if (!wanted.get(sessionId)?.has(id)) { + holds.delete(id); + this.release(hold); + } + } + if (holds.size === 0) this.holds.delete(sessionId); + } + } + + private release(hold: TerminalWindowHold): void { + const releasing = hold.dispose().finally(() => { + this.releasing.delete(releasing); + }); + this.releasing.add(releasing); + void releasing.catch((error: unknown) => { + this.ctx.logger.warn("Terminal hold release failed", error); + }); + } + + /** + * Query Host terminals without a live view or unfinished close. + * @param sessionId - Session being displayed. + * @returns terminals available for opening as recovered tabs. + */ + async recover(sessionId: SessionId): Promise { + const result = await this.remote.list(sessionId); + if (!result.ok) throw new Error(result.error.message); + const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map((view) => view.id)); + const closing = new Set(this.requests.pending().map((request) => request.id)); + return result.value.filter( + (info) => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id), + ); + } + + /** + * Retry a saved close request without reopening its tab. + * @param id - failed terminal identity. + */ + retryClose(id: WebTerminalId): void { + const record = this.requests.pending().find((item) => item.id === id); + if (record !== undefined) this.cleanup(record); + } + + private cleanup(record: TerminalCloseRequest, view?: TerminalView): void { + if (this.closing.has(record.id) || this.disposed) return; + this.closeFailures.set( + this.closeFailures.getSnapshot().filter((failure) => failure.id !== record.id), + ); + const pending = (async () => { + if (view !== undefined) await view.close(); + else { + const result = await this.remote.close(record.sessionId, record.id); + if (!result.ok) throw result.error; + } + this.requests.remove(record.id); + })() + .catch((error: unknown) => { + if (remoteErrorOf(error)?.code === "session/not-found") { + this.requests.remove(record.id); + return; + } + if (!this.disposed) + this.closeFailures.set([ + ...this.closeFailures.getSnapshot(), + { + id: record.id, + title: record.title, + message: error instanceof Error ? error.message : String(error), + }, + ]); + }) + .then(async () => { + await view?.dispose(); + this.closing.delete(record.id); + }); + this.closing.set(record.id, pending); + } +} + +/** Required Client transport and terminal namespace. */ +export const inject = ["remote", "remote.terminal"]; + +/** + * Install the Client terminal models. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + new ClientTerminals(ctx, ctx.remote.terminal); +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/model.ts b/apps/web-ui/packages/api/terminal-controller/src/client/model.ts new file mode 100644 index 000000000..57fd95eec --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/model.ts @@ -0,0 +1,459 @@ +/** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { preferredShell, rememberShell } from "./shell-preference.ts"; +import { randomUUID } from "@deepseek-ai/dsh-util-crypto"; +import { createSnapshotStore, type SnapshotStore } from "@deepseek-ai/dsh-client-store"; +import { + RemoteStreamCarrierError, + type ClientRemote, + type RemoteStream, +} from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError, remoteErrorOf, type RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type {} from "@deepseek-ai/dsh-api-terminal-controller/remote"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { + TerminalAttachmentId, + TerminalEnvironment, + TerminalFrame, + WebTerminalId, + WebTerminalInfo, +} from "../types.ts"; + +/** The generated terminal namespace's browser-facing operations. */ +export type TerminalRemote = ClientRemote["terminal"]; + +/** Product error identifiers translated by the terminal UI. */ +export type TerminalViewIssue = + "missingTerminal" | "inputFull" | "attachmentEnded" | "invalidOutput" | "terminalLimit"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** Client terminal failure preserved through the Remote stream supervisor. */ + "terminal/view": { readonly issue: TerminalViewIssue }; + } +} + +class TerminalViewError extends RemoteError<"terminal/view"> { + constructor(issue: TerminalViewIssue, message: string = issue) { + super("terminal/view", message, { issue }); + } +} + +/** One screen write awaiting the DOM emulator's callback. */ +export interface TerminalRenderFrame { + readonly revision: number; + readonly frame: Extract; +} + +/** Observable state of one sidebar occurrence. */ +export interface TerminalViewState { + readonly phase: + | "idle" + | "loading" + | "creating" + | "connecting" + | "connected" + | "disconnected" + | "closing" + | "closed" + | "failed"; + readonly environment?: TerminalEnvironment | undefined; + readonly title?: string | undefined; + readonly info?: WebTerminalInfo | undefined; + readonly writable: boolean; + readonly render?: TerminalRenderFrame | undefined; + readonly error?: string | undefined; + readonly issue?: TerminalViewIssue | undefined; +} + +/** A view survives DOM unmount; its process only ends on explicit close. */ +export class TerminalView { + /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ + readonly state: SnapshotStore = createSnapshotStore({ + phase: "idle", + writable: false, + }); + private readonly lifetime = new AbortController(); + private stream: RemoteStream | undefined; + private mounted = false; + private attachmentId: TerminalAttachmentId | undefined; + private pendingRender: { revision: number; resolve: () => void } | undefined; + private revision = 0; + private creation: Promise | undefined; + private loading: Promise | undefined; + private closing: Promise | undefined; + private writes: Promise = Promise.resolve(); + private queuedInput = 0; + private readonly detaching = new Set>(); + + /** + * @param sessionId - Session owning the terminal. + * @param remote - typed terminal Remote operations. + * @param gateway - reconnecting stream factory. + * @param id - Host terminal identity, reused when recovering an item from its Session list. + * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal. + * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell. + * @param retain - window hold acknowledgement required before output attachment. + */ + constructor( + private readonly sessionId: SessionId, + private readonly remote: TerminalRemote, + private readonly gateway: Pick, + readonly id: WebTerminalId, + private readonly createWhenMissing = true, + private readonly shellPath?: string, + private readonly retain?: (signal: AbortSignal) => Promise, + ) {} + + /** + * Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process. + * @returns a detach callback that leaves the terminal process alive. + */ + mount(): () => void { + this.mounted = true; + if (this.state.getSnapshot().info === undefined) void this.refresh(); + else this.connect(); + return () => { + this.mounted = false; + this.detach(); + }; + } + + /** + * Start or recover this tab, deduplicating mounts and retries during allocation. + * Only a new tab may allocate a shell; listed terminals cannot be silently replaced. + * @returns after environment lookup and creation or recovery settle. + */ + refresh(): Promise { + if (this.creation !== undefined) return this.creation; + if (this.loading !== undefined) return this.loading; + if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve(); + this.patch({ phase: "loading", error: undefined, issue: undefined }); + this.loading = (async () => { + const [environment, available] = await Promise.all([ + this.remote.environment(this.sessionId, this.lifetime.signal), + this.remote.list(this.sessionId), + ]); + if (this.stopped()) return; + this.patch({ environment: valueOf(environment) }); + const info = valueOf(available).find((item) => item.id === this.id); + if (info !== undefined) this.adopt(info); + else if (this.createWhenMissing) { + let path = this.shellPath; + if (path === undefined) { + const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal)); + const previous = preferredShell(); + path = shells.find((shell) => shell.path === previous)?.path ?? shells[0]?.path; + } + if (this.stopped()) return; + if (path !== undefined) rememberShell(path); + await this.create(valueOf(environment), path); + } else throw new TerminalViewError("missingTerminal"); + })() + .catch((error: unknown) => { + this.fail(error); + }) + .finally(() => { + this.loading = undefined; + }); + return this.loading; + } + + private stopped(): boolean { + return this.lifetime.signal.aborted || this.closing !== undefined; + } + + private async create(environment: TerminalEnvironment, shellPath?: string): Promise { + this.patch({ phase: "creating", error: undefined, issue: undefined }); + this.creation = (async () => { + const info = valueOf( + await this.remote.create( + this.sessionId, + { + id: this.id, + ...(shellPath === undefined ? {} : { shellPath }), + cols: Math.min(80, environment.maxCols), + rows: Math.min(24, environment.maxRows), + }, + this.lifetime.signal, + ), + ); + if (!this.lifetime.signal.aborted) { + this.adopt(info); + } + })() + .catch((error: unknown) => { + this.fail(error); + }) + .finally(() => { + this.creation = undefined; + }); + await this.creation; + } + + private adopt(info: WebTerminalInfo): void { + this.patch({ info, title: info.title }); + if (this.retain === undefined) { + if (this.mounted && this.closing === undefined) this.connect(); + return; + } + void this.retain(this.lifetime.signal) + .then(() => { + if (this.mounted && this.closing === undefined) this.connect(); + }) + .catch((error: unknown) => { + if (!this.stopped()) this.fail(error); + }); + } + + /** Reattach with a fresh screen and regain input control. */ + connect(): void { + const info = this.state.getSnapshot().info; + if ( + info === undefined || + !this.mounted || + this.closing !== undefined || + this.lifetime.signal.aborted + ) + return; + this.detach(); + const stream = this.gateway.$stream({ + name: "Browser terminal output", + open: async function* (this: TerminalView, signal: AbortSignal) { + await this.retain?.(signal); + signal.throwIfAborted(); + const attachmentId = randomUUID() as TerminalAttachmentId; + this.attachmentId = attachmentId; + yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal); + }.bind(this), + ended: () => new TerminalViewError("attachmentEnded"), + carrierFailed: () => { + if (this.stream === stream) this.patch({ phase: "disconnected", writable: false }); + }, + }); + this.stream = stream; + this.patch({ + phase: "connecting", + writable: false, + error: undefined, + issue: undefined, + render: undefined, + }); + void this.consume(stream); + } + + /** + * Release the next stream item after xterm has parsed this frame. + * @param revision - locally delivered render revision. + */ + acknowledge(revision: number): void { + if (this.pendingRender?.revision !== revision) return; + this.pendingRender.resolve(); + this.pendingRender = undefined; + } + + /** + * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. + * @param data - input from the terminal emulator. + */ + write(data: string): void { + const state = this.state.getSnapshot(); + const attachmentId = this.attachmentId; + if (!state.writable || state.info === undefined || attachmentId === undefined) return; + const bytes = new TextEncoder().encode(data).byteLength; + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { + this.fail(new TerminalViewError("inputFull")); + return; + } + this.queuedInput += bytes; + const id = state.info.id; + this.writes = this.writes + .then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return; + valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)); + }) + .catch((error: unknown) => { + if (this.attachmentId === attachmentId) this.fail(error); + }) + .finally(() => { + this.queuedInput -= bytes; + }); + } + + /** + * Resize only from the currently writable view. + * @param cols - measured column count. + * @param rows - measured row count. + */ + resize(cols: number, rows: number): void { + const state = this.state.getSnapshot(); + const attachmentId = this.attachmentId; + if (!state.writable || state.info === undefined || attachmentId === undefined) return; + if (state.info.cols === cols && state.info.rows === rows) return; + const id = state.info.id; + cols = Math.min(cols, state.environment?.maxCols ?? cols); + rows = Math.min(rows, state.environment?.maxRows ?? rows); + this.writes = this.writes + .then(async () => { + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return; + valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)); + }) + .catch((error: unknown) => { + if (this.attachmentId === attachmentId) this.fail(error); + }); + } + + /** + * Update the Host terminal's display name. + * @param title - user-entered terminal title. + * @returns after the rename settles and its result is reflected in view state. + */ + async rename(title: string): Promise { + if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return; + try { + valueOf(await this.remote.rename(this.sessionId, this.id, title)); + const current = this.state.getSnapshot().info; + this.patch({ + ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), + title: title.trim(), + }); + } catch (error) { + this.fail(error); + } + } + + /** + * Explicitly terminate this view's process independently of its DOM lifetime. + * @returns after Host process cleanup succeeds; failures remain retryable by the owner. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing; + this.patch({ phase: "closing", writable: false, error: undefined, issue: undefined }); + this.detach(); + this.closing = (async () => { + // Even a refused or lost creation response may leave an allocation to close. + await this.creation; + valueOf(await this.remote.close(this.sessionId, this.id)); + this.detach(); + this.patch({ phase: "closed", writable: false }); + })().catch((error: unknown) => { + this.closing = undefined; + this.fail(error); + throw error; + }); + return this.closing; + } + + /** + * Stop Client work on plugin unload without closing Host terminals. + * @returns after active and previously detached stream iterators have closed. + */ + async dispose(): Promise { + this.mounted = false; + this.lifetime.abort(); + this.detach(); + await Promise.all(this.detaching); + } + + private detach(): void { + const previous = this.stream; + this.stream = undefined; + this.attachmentId = undefined; + this.pendingRender?.resolve(); + this.pendingRender = undefined; + if (previous !== undefined) { + const cleanup = previous.dispose().finally(() => { + this.detaching.delete(cleanup); + }); + this.detaching.add(cleanup); + } + } + + private async consume(stream: RemoteStream): Promise { + let generation = 0; + let sequence = 0; + try { + for await (const item of stream) { + if (this.stream !== stream) return; + const frame = item.value; + if (generation !== item.generation) { + if (frame.type !== "snapshot") + throw new TerminalViewError( + "invalidOutput", + "Terminal output generation is missing its screen snapshot", + ); + generation = item.generation; + sequence = frame.sequence; + item.accept(); + } else if (frame.type === "output") { + if (frame.sequence !== sequence + 1) + throw new TerminalViewError("invalidOutput", "Terminal output sequence has a gap"); + sequence = frame.sequence; + } else if (frame.type === "snapshot") + throw new TerminalViewError("invalidOutput", "Unexpected terminal screen snapshot"); + if (frame.type !== "output") { + this.patch({ + info: frame.info, + title: frame.info.title, + phase: "connected", + writable: + frame.info.state === "running" && frame.info.controllerId === this.attachmentId, + }); + } + if (frame.type !== "state") { + const revision = ++this.revision; + await new Promise((resolve) => { + this.pendingRender = { revision, resolve }; + const aborted = (): void => { + this.acknowledge(revision); + }; + item.signal.addEventListener("abort", aborted, { once: true }); + this.pendingRender.resolve = () => { + item.signal.removeEventListener("abort", aborted); + resolve(); + }; + this.patch({ render: { revision, frame } }); + if (item.signal.aborted) aborted(); + }); + } + } + } catch (error) { + if (this.stream === stream) { + if (this.state.getSnapshot().info?.state === "exited") + this.patch({ phase: "closed", writable: false }); + else this.fail(error); + } + } + } + + private patch(patch: Partial): void { + if (this.lifetime.signal.aborted) return; + this.state.set({ ...this.state.getSnapshot(), ...patch }); + } + + private fail(error: unknown): void { + const failure = remoteErrorOf(error); + if (failure?.code === "terminal/control-unavailable") { + this.patch({ writable: false, error: undefined, issue: undefined }); + return; + } + const issue = + failure?.code === "terminal/view" + ? failure.details.issue + : failure?.code === "terminal/limit-reached" + ? "terminalLimit" + : failure?.code === "terminal/unavailable" + ? "missingTerminal" + : undefined; + this.patch({ + phase: error instanceof RemoteStreamCarrierError ? "disconnected" : "failed", + writable: false, + issue, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function valueOf(result: RemoteResult): T { + if (!result.ok) throw result.error; + return result.value; +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts b/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts new file mode 100644 index 000000000..12f196a82 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/retention.ts @@ -0,0 +1,92 @@ +/** One reconnecting window hold, shared by all occurrences of a terminal. */ +import type { ClientRemote, RemoteStream } from "@deepseek-ai/dsh-api-gateway/client"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { TerminalRetentionFrame, WebTerminalId } from "../types.ts"; +import type { TerminalRemote } from "./model.ts"; + +/** A stream acknowledgement gates output attachment for each physical connection. */ +export class TerminalWindowHold { + private readonly stream: RemoteStream; + private readonly waiters = new Set<{ resolve: () => void; reject: (error: unknown) => void }>(); + private generation: AbortSignal | undefined; + private failure: Error | undefined; + + /** + * @param gateway - reconnecting stream owner. + * @param remote - typed terminal namespace. + * @param sessionId - saved layout's Session, without Agent activation. + * @param id - existing Host terminal. + */ + constructor( + gateway: Pick, + remote: TerminalRemote, + sessionId: SessionId, + id: WebTerminalId, + ) { + this.stream = gateway.$stream({ + name: "Browser terminal window hold", + open: (signal) => remote.retain(sessionId, id, signal), + ended: () => new RemoteError("terminal/unavailable", "Terminal hold ended", {}), + }); + void this.consume(); + } + + /** Whether a terminal-domain failure ended this hold, allowing an explicit retry. */ + get failed(): boolean { + return this.failure !== undefined; + } + + /** + * Wait for an acknowledged current physical hold before following its screen. + * @param signal - output request or view lifetime. + * @returns after acknowledgement, or rejects on cancellation/unavailability. + */ + async ready(signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (this.failure !== undefined) throw this.failure; + if (this.generation !== undefined && !this.generation.aborted) return; + const waiting = Promise.withResolvers(); + const abort = (): void => { + waiting.reject(signal.reason); + }; + this.waiters.add(waiting); + signal.addEventListener("abort", abort, { once: true }); + try { + await waiting.promise; + } finally { + this.waiters.delete(waiting); + signal.removeEventListener("abort", abort); + } + } + + /** + * Release this window's stream and all acknowledgement waiters. + * @returns after the stream consumer closes. + */ + dispose(): Promise { + this.reject(new Error("Terminal window hold released")); + return this.stream.dispose(); + } + + private async consume(): Promise { + try { + for await (const item of this.stream) { + item.accept(); + this.generation = item.signal; + for (const waiter of this.waiters) waiter.resolve(); + this.waiters.clear(); + } + } catch (error) { + this.reject(error); + } + } + + private reject(error: unknown): void { + this.failure = + error instanceof Error ? error : new Error("Terminal window hold failed", { cause: error }); + this.generation = undefined; + for (const waiter of this.waiters) waiter.reject(error); + this.waiters.clear(); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts b/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts new file mode 100644 index 000000000..da2144b1a --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/client/shell-preference.ts @@ -0,0 +1,26 @@ +/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */ +const KEY = "dsh.terminal.shell"; + +/** + * Read the browser preference. + * @returns the last selected shell path, or null when storage is unavailable. + */ +export function preferredShell(): string | null { + try { + return typeof localStorage === "undefined" ? null : localStorage.getItem(KEY); + } catch (_storageUnavailable) { + return null; + } +} + +/** + * Remember the selected shell without making storage a startup dependency. + * @param path - verified executable path offered by the Host. + */ +export function rememberShell(path: string): void { + try { + if (typeof localStorage !== "undefined") localStorage.setItem(KEY, path); + } catch (_storageUnavailable) { + /* Private browsing or quota failure leaves this launch usable. */ + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/index.ts b/apps/web-ui/packages/api/terminal-controller/src/index.ts new file mode 100644 index 000000000..14c80311d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/index.ts @@ -0,0 +1,548 @@ +/** Session-owned user terminals with the execution environment's system-user permissions. */ +import type { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { discoverShells, resolveShell } from "./shells.ts"; +import { BrowserTerminal } from "./terminal.ts"; +import { TerminalRetention } from "./retention.ts"; +import type { + TerminalShell, + TerminalAttachmentId, + TerminalCreateRequest, + TerminalEnvironment, + TerminalFrame, + TerminalRetentionFrame, + WebTerminalId, + WebTerminalInfo, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Interactive user terminals, separate from the Agent terminal tool registry. */ + terminalController: TerminalController; + } +} + +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: + | { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string; + /** User-visible profile name. */ + name: string; + /** Arguments passed to the interactive shell. */ + args: string[]; + } + | undefined; + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[]; + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number; + /** Maximum terminal width in columns. */ + readonly maxCols: number; + /** Maximum terminal height in rows. */ + readonly maxRows: number; + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number; + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number; + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number; + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number; + /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */ + readonly unattendedTimeoutMs: number; + /** Interval between unattended shell and process observations. */ + readonly activityPollIntervalMs: number; + /** Delay before retrying failed owned terminal cleanup. */ + readonly cleanupRetryMs: number; +} + +interface TerminalAllocation { + info: WebTerminalInfo; + readonly cleanup: TerminalRetention; +} + +interface OwnedSession { + readonly lifetime: AbortController; + readonly closedIds: Set; + cleanup?: Promise; + readonly terminals: Map; + readonly pending: Map>; + readonly allocations: Map; +} + +/** Typed Remote control of transient Session-owned terminal processes. */ +export class TerminalController extends TypertRemoteService { + static inject = ["subprocess", "sandboxPolicy", "typert"]; + static Config: z = z.object({ + shell: z.union([ + z.object({ + path: z.string().required(), + name: z.string().required(), + args: z.array(z.string()).default([]), + }), + z.const(undefined), + ]), + shellCandidates: z + .array(z.string().min(1)) + .default(["zsh", "bash", "fish", "pwsh", "powershell", "cmd"]), + maxTerminals: z.number().step(1).min(1).default(8), + maxCols: z.number().step(1).min(2).default(500), + maxRows: z.number().step(1).min(1).default(200), + scrollback: z.number().step(1).min(0).default(1000), + maxBufferedBytes: z + .number() + .step(1) + .min(1024) + .default(2 * 1024 * 1024), + maxInputBytes: z + .number() + .step(1) + .min(1) + .default(64 * 1024), + disposeGraceMs: z.number().step(1).min(1).default(1000), + unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000), + activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000), + cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000), + }); + + private readonly owners = new Map(); + private readonly lifetime = new AbortController(); + + /** + * @param ctx - Host context carrying typed Remote and execution providers. + * @param config - validated terminal limits and optional shell profile. + */ + constructor( + ctx: Context, + private readonly config: Config, + ) { + super(ctx, "terminalController", { namespace: "terminal" }); + ctx.effect( + () => async () => { + this.lifetime.abort(new Error("Terminal controller disposed")); + const results = await Promise.allSettled( + [...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)), + ); + const errors = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, "Browser terminal cleanup failed"); + }, + "terminal-controller.processes", + ); + } + + /** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ + @Remote + environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { + signal.throwIfAborted(); + const { sandboxPolicy } = this.execution(agent); + return { + cwd: agent.session.header.cwd ?? sandboxPolicy.workspaceRoot, + maxInputBytes: this.config.maxInputBytes, + maxCols: this.config.maxCols, + maxRows: this.config.maxRows, + scrollback: this.config.scrollback, + }; + } + + /** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ + @Remote + shells(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return discoverShells( + this.execution(agent).subprocess, + this.config.shell, + this.config.shellCandidates, + signal, + ); + } + + /** + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. + * @returns terminals retained for this Host lifetime. + */ + @Remote + list(sessionId: SessionId): WebTerminalInfo[] { + const owner = this.owners.get(sessionId); + if (owner === undefined) return []; + return [...owner.terminals.values(), ...owner.allocations.values()].map( + (terminal) => terminal.info, + ); + } + + /** + * Allocate a user shell once for a caller-generated identity, without Agent sandbox or approval restrictions. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ + @Remote + async create( + agent: Agent, + request: TerminalCreateRequest, + signal: AbortSignal, + ): Promise { + this.lifetime.signal.throwIfAborted(); + if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error("Invalid terminal identity"); + this.dimensions(request.cols, request.rows); + const owner = this.owner(agent); + owner.lifetime.signal.throwIfAborted(); + this.requireOpen(owner, request.id); + const existing = owner.terminals.get(request.id); + if (existing !== undefined) return existing.info; + const pending = owner.pending.get(request.id); + if (pending !== undefined) { + const terminal = await pending; + this.requireOpen(owner, request.id); + return terminal.info; + } + if ( + new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]) + .size >= this.config.maxTerminals + ) + throw new RemoteError("terminal/limit-reached", "Session terminal limit reached", { + limit: this.config.maxTerminals, + }); + const allocation = this.spawn( + agent, + owner, + request, + AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]), + ); + owner.pending.set(request.id, allocation); + try { + const terminal = await allocation; + owner.terminals.set(request.id, terminal); + owner.allocations.delete(request.id); + terminal.monitor( + this.config, + () => { + owner.closedIds.add(request.id); + }, + () => { + owner.terminals.delete(request.id); + }, + (error) => { + this.ctx.logger.error("Browser terminal cleanup failed", error); + }, + ); + this.requireOpen(owner, request.id); + return terminal.info; + } finally { + owner.pending.delete(request.id); + } + } + + /** + * Retain an existing terminal for a window without activating its Agent or taking input control. + * @param sessionId - owning Session identity, including an inactive saved layout. + * @param id - retained Host terminal identity. + * @param signal - physical Remote stream cancellation. + * @returns a hold acknowledgement followed by an open lifetime stream. + */ + @Remote({ mode: "stream" }) + retain( + sessionId: SessionId, + id: WebTerminalId, + signal: AbortSignal, + ): AsyncIterable { + const owner = this.owners.get(sessionId); + const terminal = owner?.terminals.get(id); + if ( + terminal === undefined || + owner?.closedIds.has(id) === true || + owner?.lifetime.signal.aborted === true + ) { + throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {}); + } + return terminal.retain(signal); + } + + /** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ + @Remote({ mode: "stream" }) + follow( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + signal: AbortSignal, + ): AsyncIterable { + if (!/^[\w-]{1,128}$/u.test(attachmentId)) + throw new Error("Invalid terminal attachment identity"); + return this.terminal(agent, id).follow(attachmentId, signal); + } + + /** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ + @Remote + async write( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + data: string, + ): Promise { + if (Buffer.byteLength(data, "utf8") > this.config.maxInputBytes) + throw new Error("Terminal input exceeds the configured limit"); + await this.terminal(agent, id).write(attachmentId, data); + } + + /** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ + @Remote + async resize( + agent: Agent, + id: WebTerminalId, + attachmentId: TerminalAttachmentId, + cols: number, + rows: number, + ): Promise { + this.dimensions(cols, rows); + await this.terminal(agent, id).resize(attachmentId, cols, rows); + } + + /** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ + @Remote + rename(agent: Agent, id: WebTerminalId, title: string): void { + if (title.trim().length === 0 || title.length > 120) + throw new Error("Terminal title must contain 1–120 characters"); + this.terminal(agent, id).rename(title.trim()); + } + + /** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ + @Remote + async close(agent: Agent, id: WebTerminalId): Promise { + const owner = this.owner(agent); + owner.closedIds.add(id); + // create publishes the allocation before this wait settles; close owns it even if create then rejects. + await owner.pending.get(id)?.catch(() => { + /* Creation reports its failure; close still owns any allocated process. */ + }); + const terminal = owner.terminals.get(id); + if (terminal !== undefined) { + await terminal.close(); + owner.terminals.delete(id); + } else { + const allocation = owner.allocations.get(id); + if (allocation === undefined) return; + await allocation.cleanup.close(); + owner.allocations.delete(id); + } + } + + private owner(agent: Agent): OwnedSession { + let owner = this.owners.get(agent.id); + if (owner === undefined) { + owner = { + terminals: new Map(), + pending: new Map(), + allocations: new Map(), + closedIds: new Set(), + lifetime: new AbortController(), + }; + this.owners.set(agent.id, owner); + const owned = owner; + agent.ctx.effect( + () => async () => { + await this.disposeOwner(agent.id, owned); + }, + "terminal-controller.owner", + ); + } + return owner; + } + + private disposeOwner(id: SessionId, owner: OwnedSession): Promise { + if (owner.cleanup !== undefined) return owner.cleanup; + owner.lifetime.abort(new Error("Terminal Session owner disposed")); + owner.cleanup = (async () => { + await Promise.allSettled(owner.pending.values()); + const results = await Promise.allSettled([ + ...[...owner.terminals.values()].map((terminal) => terminal.dispose()), + ...[...owner.allocations.values()].map((allocation) => allocation.cleanup.dispose()), + ]); + const errors = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, "Session terminal cleanup failed"); + owner.terminals.clear(); + owner.allocations.clear(); + this.owners.delete(id); + })().catch((error: unknown) => { + delete owner.cleanup; + throw error; + }); + return owner.cleanup; + } + + private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal { + const terminal = this.owners.get(agent.id)?.terminals.get(id); + if (terminal === undefined) + throw new RemoteError( + "terminal/unavailable", + "Terminal no longer exists in this Session", + {}, + ); + this.requireOpen(this.owners.get(agent.id) as OwnedSession, id); + return terminal; + } + + private requireOpen(owner: OwnedSession, id: WebTerminalId): void { + if (owner.closedIds.has(id)) + throw new RemoteError("terminal/unavailable", "Terminal was closed in this Session", {}); + } + + private dimensions(cols: number, rows: number): void { + if ( + !Number.isSafeInteger(cols) || + cols < 2 || + cols > this.config.maxCols || + !Number.isSafeInteger(rows) || + rows < 1 || + rows > this.config.maxRows + ) + throw new Error("Terminal dimensions exceed the configured limits"); + } + + private execution(agent: Agent): { + subprocess: Context["subprocess"]; + sandboxPolicy: Context["sandboxPolicy"]; + } { + // The Agent context selects execution providers but does not inject consumer services. + const subprocess = agent.ctx.get("subprocess"); + const sandboxPolicy = agent.ctx.get("sandboxPolicy"); + if (subprocess === undefined || sandboxPolicy === undefined) + throw new Error( + "The Session execution environment requires subprocess and sandbox policy providers", + ); + return { subprocess, sandboxPolicy }; + } + + private async spawn( + agent: Agent, + owner: OwnedSession, + request: TerminalCreateRequest, + signal: AbortSignal, + ): Promise { + const environment = this.environment(agent, signal); + const { subprocess } = this.execution(agent); + const shell = + request.shellPath === undefined + ? await resolveShell(subprocess, this.config.shell, signal) + : (await this.shells(agent, signal)).find( + (candidate) => candidate.path === request.shellPath, + ); + if (shell === undefined) + throw new Error("Selected shell is not available in this execution environment"); + const handle = await subprocess.spawnTerminal({ + argv: [shell.path, ...shell.args], + cwd: environment.cwd, + cols: request.cols, + rows: request.rows, + terminalType: "xterm-256color", + env: { DSH_SESSION_ID: agent.id }, + shellActivity: true, + graceMs: this.config.disposeGraceMs, + signal, + }); + const info: WebTerminalInfo = { + id: request.id, + shell, + title: shell.name, + cwd: environment.cwd, + cols: request.cols, + rows: request.rows, + state: "running", + exitCode: null, + }; + try { + signal.throwIfAborted(); + return new BrowserTerminal( + handle, + info, + this.config.scrollback, + this.config.maxBufferedBytes, + ); + } catch (error) { + const cleanup = new TerminalRetention( + this.config, + handle.inspectActivity.bind(handle), + async () => { + owner.closedIds.add(request.id); + await handle.terminate(); + owner.allocations.delete(request.id); + }, + (cleanupError) => { + this.ctx.logger.error("Browser terminal allocation cleanup failed", cleanupError); + }, + ); + owner.allocations.set(request.id, { + info: { + ...info, + state: "failed", + error: error instanceof Error ? error.message : String(error), + }, + cleanup, + }); + try { + await cleanup.close(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], "Terminal allocation cleanup failed"); + } + throw error; + } + } +} + +/** Browser terminal service plugin. */ +export default TerminalController; diff --git a/apps/web-ui/packages/api/terminal-controller/src/retention.ts b/apps/web-ui/packages/api/terminal-controller/src/retention.ts new file mode 100644 index 000000000..93a89d892 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/retention.ts @@ -0,0 +1,168 @@ +/** Window holds and conservative idle reclamation for one terminal owner. */ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { SubprocessTerminalActivity } from "@deepseek-ai/dsh-subprocess"; +import type { TerminalRetentionFrame } from "./types.ts"; + +/** Validated Host timing policy for unattended terminal cleanup. */ +export interface TerminalRetentionPolicy { + readonly unattendedTimeoutMs: number; + readonly activityPollIntervalMs: number; + readonly cleanupRetryMs: number; +} + +/** Exactly one owner orders holds, observation, and retryable process cleanup. */ +export class TerminalRetention { + private readonly lifetime = new AbortController(); + private readonly holders = new Set(); + private epoch = 0; + private timer: ReturnType | undefined; + private observation: Promise | undefined; + private idle: { since: number; revision: number; observedAt: number } | undefined; + private closing = false; + private disposed = false; + private cleanup: Promise | undefined; + + /** + * @param policy - deployment timing choices. + * @param inspect - fresh shell and owned-job observation. + * @param terminate - mark the identity closed, await process quiescence, and remove its owner record. + * @param failed - diagnostic sink for failed automatic cleanup. + */ + constructor( + private readonly policy: TerminalRetentionPolicy, + private readonly inspect: () => Promise, + private readonly terminate: () => Promise, + private readonly failed: (error: unknown) => void, + ) { + this.schedule(0); + } + + /** + * Hold one terminal for one physical Remote stream, independently of screen subscriptions. + * @param signal - transport generation lifetime. + * @returns acknowledgement followed by an open stream until cancellation or terminal closure. + */ + async *retain(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + if (this.closing || this.disposed) + throw new RemoteError("terminal/unavailable", "Terminal is closing or unavailable", {}); + const holder = {}; + const ended = Promise.withResolvers(); + const combined = AbortSignal.any([signal, this.lifetime.signal]); + const release = (): void => { + if (!this.holders.delete(holder)) return; + combined.removeEventListener("abort", release); + this.invalidate(); + ended.resolve(); + this.schedule(0); + }; + this.holders.add(holder); + this.invalidate(); + this.cancelTimer(); + combined.addEventListener("abort", release, { once: true }); + try { + yield { type: "retained" }; + await ended.promise; + } finally { + release(); + } + } + + /** Invalidate outstanding idle observations before accepting input. */ + invalidate(): void { + this.epoch++; + this.idle = undefined; + } + + /** + * Start or join cleanup; failure keeps the identity closed and schedules one retry. + * @returns after owned process cleanup succeeds, or rejects with its failure. + */ + close(): Promise { + if (this.cleanup !== undefined) return this.cleanup; + this.closing = true; + this.invalidate(); + this.lifetime.abort(new Error("Terminal closed")); + this.cancelTimer(); + this.cleanup = this.terminate().catch((error: unknown) => { + this.cleanup = undefined; + this.schedule(this.policy.cleanupRetryMs); + throw error; + }); + return this.cleanup; + } + + /** + * Stop timers and streams and await both observation and final cleanup. + * @returns after process quiescence; cleanup failure is reported to the disposing owner. + */ + async dispose(): Promise { + this.disposed = true; + this.cancelTimer(); + const observation = this.observation; + try { + await this.close(); + } finally { + await observation; + } + } + + private cancelTimer(): void { + clearTimeout(this.timer); + this.timer = undefined; + } + + private schedule(delay: number): void { + if (this.disposed || this.timer !== undefined) return; + if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return; + const due = performance.now() + delay; + this.timer = setTimeout( + () => { + this.timer = undefined; + const remaining = due - performance.now(); + if (remaining > 0) { + this.schedule(remaining); + return; + } + if (this.closing) { + void this.close().catch(this.failed); + return; + } + this.observe(); + }, + Math.min(delay, 2_147_483_647), + ); + this.timer.unref(); + } + + private observe(): void { + if (this.observation !== undefined) return; + const epoch = this.epoch; + this.observation = (async () => { + let activity: SubprocessTerminalActivity; + try { + activity = await this.inspect(); + } catch (_activityUnavailable) { + activity = { state: "unknown", revision: 0 }; + } + if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return; + const now = performance.now(); + if (activity.state !== "idle") { + this.idle = undefined; + return; + } + if ( + this.idle?.revision !== activity.revision || + now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2 + ) { + this.idle = { since: now, observedAt: now, revision: activity.revision }; + } else this.idle.observedAt = now; + if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close(); + })() + .catch(this.failed) + .finally(() => { + this.observation = undefined; + if (!this.closing) this.schedule(this.policy.activityPollIntervalMs); + }); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/shells.ts b/apps/web-ui/packages/api/terminal-controller/src/shells.ts new file mode 100644 index 000000000..face12a0d --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/shells.ts @@ -0,0 +1,86 @@ +/** Shell selection and executable verification use the target execution provider. */ +import type { SubprocessRuntime } from "@deepseek-ai/dsh-subprocess"; +import type { TerminalShell } from "./types.ts"; + +/** + * Resolve the configured shell or the execution environment's default shell. + * @param subprocess - target execution provider. + * @param configured - optional profile overriding the environment's default shell. + * @param signal - resolution cancellation. + * @returns one verified shell; a declared default that cannot resolve rejects. + */ +export async function resolveShell( + subprocess: SubprocessRuntime, + configured: TerminalShell | undefined, + signal: AbortSignal, +): Promise { + let shell = configured; + if (shell === undefined) { + const environment = await subprocess.terminalEnvironment(signal); + shell = profile( + environment.defaultShell ?? (environment.platform === "windows" ? "cmd.exe" : "/bin/sh"), + ); + } + const path = await subprocess.resolveExecutable(shell.path, undefined, signal); + return { ...shell, path }; +} + +function profile(path: string): TerminalShell { + const kind = shellKind(path); + return { + path, + name: executableName(path), + args: kind === "cmd" ? [] : kind === "pwsh" || kind === "powershell" ? ["-NoLogo"] : ["-i"], + }; +} + +function executableName(path: string): string { + return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1); +} + +function shellKind(path: string): string { + return executableName(path) + .toLowerCase() + .replace(/\.exe$/u, ""); +} + +/** + * List verified candidates after the configured or environment-default shell, one per executable name ignoring case and `.exe`. + * PATH lookup can reach the default through another directory, such as `/usr/bin/bash` for `/bin/bash` on merged-`/usr` systems. + * @param subprocess - target execution provider. + * @param configured - optional default profile. + * @param candidates - executable names or paths permitted for shell selection. + * @param signal - discovery cancellation. + * @returns installed shells with the default first, keeping the earliest entry per name; transport failures reject. + */ +export async function discoverShells( + subprocess: SubprocessRuntime, + configured: TerminalShell | undefined, + candidates: readonly string[], + signal: AbortSignal, +): Promise { + const preferred = await resolveShell(subprocess, configured, signal); + const found = await Promise.all( + candidates.map(async (candidate) => { + try { + return await resolveShell(subprocess, profile(candidate), signal); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "SubprocessExecutableNotFoundError" + ) + return undefined; + throw error; + } + }), + ); + const shells = new Map(); + for (const shell of [preferred, ...found]) { + if (shell === undefined) continue; + const kind = shellKind(shell.path); + if (!shells.has(kind)) shells.set(kind, shell); + } + return [...shells.values()]; +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/stream.ts b/apps/web-ui/packages/api/terminal-controller/src/stream.ts new file mode 100644 index 000000000..c77319dd7 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/stream.ts @@ -0,0 +1,81 @@ +/** A bounded output queue for one Remote stream generation. */ +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { TerminalFrame } from "./types.ts"; + +/** Slow followers fail explicitly; a later attachment recovers from the screen. */ +export class TerminalFollower { + private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>(); + private bytes = 0; + private wake: (() => void) | undefined; + private closed = false; + private finished = false; + private failure: Error | undefined; + + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ + constructor(private readonly maxBytes: number) {} + + /** + * Queue a frame or fail this follower when its byte limit is exceeded. + * @param frame - next ordered frame. + */ + push(frame: TerminalFrame): void { + if (this.closed || this.finished) return; + const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8"); + if (this.bytes + bytes > this.maxBytes) { + this.failure = new Error( + "Terminal output consumer exceeded its buffer; reconnect to recover the current screen", + ); + this.close(); + return; + } + this.queue.pushBack({ frame, bytes }); + this.bytes += bytes; + this.wake?.(); + } + + /** Finish after delivering every queued frame, including the final exit state. */ + finish(): void { + this.finished = true; + this.wake?.(); + } + + /** Stop this follower without stopping its terminal. */ + close(): void { + this.closed = true; + this.queue.clear(); + this.bytes = 0; + this.wake?.(); + } + + /** + * Drain until detached or failed. + * @param signal - Remote generation cancellation. + * @returns ordered terminal frames. + */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { + this.close(); + }; + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + try { + while (!this.closed) { + const next = this.queue.popFront(); + if (next !== undefined) { + this.bytes -= next.bytes; + yield next.frame; + } else { + if (this.finished) break; + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + } + if (this.failure !== undefined) throw this.failure; + } finally { + signal.removeEventListener("abort", abort); + this.close(); + } + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/terminal.ts b/apps/web-ui/packages/api/terminal-controller/src/terminal.ts new file mode 100644 index 000000000..fecbc73a2 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/terminal.ts @@ -0,0 +1,266 @@ +/** One PTY, a bounded terminal emulator and its detachable browser followers. */ +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { Terminal as HeadlessTerminal } from "@xterm/headless"; +import type { SerializeAddon as Serializer } from "@xterm/addon-serialize"; +import type { SubprocessTerminalHandle } from "@deepseek-ai/dsh-subprocess"; +import { createLazyRequire } from "@deepseek-ai/dsh-lazy-require"; +import { TerminalFollower } from "./stream.ts"; +import { TerminalRetention, type TerminalRetentionPolicy } from "./retention.ts"; +import type { + TerminalAttachmentId, + TerminalFrame, + TerminalRetentionFrame, + WebTerminalInfo, +} from "./types.ts"; + +const requireHeadless = createLazyRequire( + "@xterm/headless", + import.meta.url, +); +const requireSerialize = createLazyRequire( + "@xterm/addon-serialize", + import.meta.url, +); + +/** Process lifetime is independent of follower and component lifetimes. */ +export class BrowserTerminal { + private readonly screen: HeadlessTerminal; + private readonly serializer: Serializer; + private readonly followers = new Set(); + private sequence = 0; + private operations: Promise = Promise.resolve(); + private readonly drained: Promise; + private closing: Promise | undefined; + private retention: TerminalRetention | undefined; + private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined; + + /** + * @param handle - allocated terminal process range. + * @param info - initial metadata. + * @param scrollback - maximum retained scrollback rows. + * @param maxBufferedBytes - per-follower queue cap. + */ + constructor( + private readonly handle: SubprocessTerminalHandle, + public info: WebTerminalInfo, + scrollback: number, + private readonly maxBufferedBytes: number, + ) { + const { Terminal } = requireHeadless(); + const { SerializeAddon } = requireSerialize(); + this.screen = new Terminal({ + cols: info.cols, + rows: info.rows, + scrollback, + allowProposedApi: true, + }); + this.serializer = new SerializeAddon(); + this.screen.loadAddon(this.serializer); + this.drained = this.consume(); + } + + /** + * Start monitoring after this allocation is committed to its Session owner. + * @param policy - validated Host timing policy. + * @param closing - closes the id before any asynchronous termination. + * @param closed - removes the exact successfully terminated owner record. + * @param failed - diagnostic sink for background cleanup failure. + */ + monitor( + policy: TerminalRetentionPolicy, + closing: () => void, + closed: () => void, + failed: (error: unknown) => void, + ): void { + this.retention = new TerminalRetention( + policy, + () => this.handle.inspectActivity(), + async () => { + closing(); + await this.closeProcess(); + closed(); + }, + failed, + ); + } + + /** + * Retain this committed process independently of output attachment. + * @param signal - physical window stream lifetime. + * @returns its hold acknowledgement and lifetime. + */ + retain(signal: AbortSignal): AsyncIterable { + if (this.retention === undefined) throw new Error("Terminal has not been committed"); + return this.retention.retain(signal); + } + + /** + * Attach with exclusive input control; an older attachment becomes read-only. + * @param id - browser attachment identity. + * @param signal - attachment cancellation; never terminates the process. + * @returns a consistent screen followed by ordered output and state changes. + */ + async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const follower = new TerminalFollower(this.maxBufferedBytes); + const baseline = await this.enqueue(() => { + signal.throwIfAborted(); + this.controller = { id, follower }; + this.info = { ...this.info, controllerId: id }; + this.broadcast({ type: "state", info: this.info }); + const snapshot: TerminalFrame = { + type: "snapshot", + sequence: this.sequence, + screen: this.serializer.serialize(), + info: this.info, + }; + this.followers.add(follower); + return snapshot; + }); + try { + yield baseline; + yield* follower.read(signal); + } finally { + this.followers.delete(follower); + follower.close(); + if (this.controller?.follower === follower) { + this.controller = undefined; + const { controllerId: _controllerId, ...info } = this.info; + this.info = info; + this.broadcast({ type: "state", info }); + } + } + } + + /** + * Send raw terminal input without command interpretation. + * @param id - current writable attachment. + * @param data - UTF-8 input, including shell completion/control keys. + * @returns when the provider accepts the input. + */ + write(id: TerminalAttachmentId, data: string): Promise { + this.retention?.invalidate(); + return this.enqueue(async () => { + this.requireController(id); + await this.handle.write(data); + }); + } + + /** + * Resize the PTY and recovery screen in the same operation order as output. + * @param id - current writable attachment. + * @param cols - validated column count. + * @param rows - validated row count. + * @returns when the provider and emulator use the new dimensions. + */ + resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { + return this.enqueue(async () => { + this.requireController(id); + await this.handle.resize(cols, rows); + this.screen.resize(cols, rows); + this.info = { ...this.info, cols, rows }; + this.broadcast({ type: "state", info: this.info }); + }); + } + + /** + * Publish a display name to every attached view. + * @param title - validated user title. + */ + rename(title: string): void { + this.info = { ...this.info, title }; + this.broadcast({ type: "state", info: this.info }); + } + + /** + * Terminate the complete provider-owned process range before releasing its screen. + * @returns after process cleanup and final output drainage; failures remain retryable. + */ + close(): Promise { + return this.retention?.close() ?? this.closeProcess(); + } + + /** + * Stop unattended cleanup scheduling and await final process cleanup. + * @returns after terminal and monitor quiescence. + */ + dispose(): Promise { + return this.retention?.dispose() ?? this.closeProcess(); + } + + private closeProcess(): Promise { + if (this.closing !== undefined) return this.closing; + this.closing = (async () => { + await this.handle.terminate(); + await this.drained; + for (const follower of this.followers) follower.finish(); + this.followers.clear(); + this.screen.dispose(); + })().catch((error: unknown) => { + this.closing = undefined; + throw error; + }); + return this.closing; + } + + private requireController(id: TerminalAttachmentId): void { + if (this.closing !== undefined || this.info.state !== "running") + throw new RemoteError("terminal/control-unavailable", "Terminal is not running", { + reason: "not-running", + }); + if (this.controller?.id !== id) + throw new RemoteError( + "terminal/control-unavailable", + "Terminal input is controlled by another attachment", + { reason: "read-only" }, + ); + } + + private broadcast(frame: TerminalFrame): void { + for (const follower of this.followers) follower.push(frame); + } + + private enqueue(operation: () => T | Promise): Promise { + const pending = this.operations.then(operation); + this.operations = pending.catch(() => { + /* The caller owns this operation's failure; later cleanup must still run. */ + }); + return pending; + } + + private async consume(): Promise { + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + const outcome = this.handle.done.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + try { + for await (const chunk of this.handle.output) { + // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. + const data = decoder.decode(chunk as Buffer, { stream: true }); + await this.output(data); + } + await this.output(decoder.decode()); + const result = await outcome; + if ("error" in result) throw result.error; + this.info = { ...this.info, state: "exited", exitCode: result.value.exitCode }; + } catch (error) { + this.info = { + ...this.info, + state: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + this.broadcast({ type: "state", info: this.info }); + } + + private async output(data: string): Promise { + if (data.length === 0) return; + await this.enqueue(async () => { + await new Promise((resolve) => { + this.screen.write(data, resolve); + }); + this.broadcast({ type: "output", sequence: ++this.sequence, data }); + }); + } +} diff --git a/apps/web-ui/packages/api/terminal-controller/src/types.ts b/apps/web-ui/packages/api/terminal-controller/src/types.ts new file mode 100644 index 000000000..8fc0e9f7b --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/src/types.ts @@ -0,0 +1,75 @@ +/** Browser terminal identities, metadata and screen-stream frames. */ +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type {} from "@deepseek-ai/dsh-typert-protocol"; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The terminal identity is missing or has begun process cleanup. */ + "terminal/unavailable": Record; + /** Input or resize was refused without invalidating the output attachment. */ + "terminal/control-unavailable": { readonly reason: "read-only" | "not-running" }; + /** Retained screens and pending allocations consume the Session's terminal quota. */ + "terminal/limit-reached": { readonly limit: number }; + } +} + +/** A terminal identity scoped to one Session and one Host lifetime. */ +export type WebTerminalId = Branded<"WebTerminalId">; +/** An attachment allowed to write and resize one terminal. */ +export type TerminalAttachmentId = Branded<"TerminalAttachmentId">; + +/** Acknowledges one physical window hold without taking screen or input control. */ +export interface TerminalRetentionFrame { + readonly type: "retained"; +} + +/** An executable shell verified in the subprocess provider's execution environment. */ +export interface TerminalShell { + readonly path: string; + readonly args: readonly string[]; + readonly name: string; +} + +/** Working directory and limits shared by new and restored terminals. */ +export interface TerminalEnvironment { + readonly cwd: string; + readonly maxInputBytes: number; + readonly maxCols: number; + readonly maxRows: number; + readonly scrollback: number; +} + +/** Host-owned terminal state; process exit never creates a replacement shell. */ +export interface WebTerminalInfo { + readonly id: WebTerminalId; + readonly title: string; + readonly shell: TerminalShell; + /** Initial working directory; shell directory changes do not update this field. */ + readonly cwd: string; + readonly cols: number; + readonly rows: number; + readonly state: "running" | "exited" | "failed"; + readonly exitCode: number | null; + readonly error?: string; + readonly controllerId?: TerminalAttachmentId; +} + +/** Create is idempotent for an open identity; closed identities cannot be recreated. */ +export interface TerminalCreateRequest { + /** A path returned by shell discovery; absent selects the execution default. */ + readonly shellPath?: string; + readonly id: WebTerminalId; + readonly cols: number; + readonly rows: number; +} + +/** Every attachment begins with a complete bounded screen, then ordered output. */ +export type TerminalFrame = + | { + readonly type: "snapshot"; + readonly sequence: number; + readonly screen: string; + readonly info: WebTerminalInfo; + } + | { readonly type: "output"; readonly sequence: number; readonly data: string } + | { readonly type: "state"; readonly info: WebTerminalInfo }; diff --git a/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts b/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts new file mode 100644 index 000000000..718035751 --- /dev/null +++ b/apps/web-ui/packages/api/terminal-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-terminal-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts b/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts new file mode 100644 index 000000000..bffde02e4 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/generated/remote.d.ts @@ -0,0 +1,53 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, WorkspaceCreateRequest, WorkspaceCreateValue, WorkspaceDeleteRequest, WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, WorkspaceRenameRequest, WorkspaceUnarchiveSessionRequest, WorkspaceUnpinSessionRequest, WorkspaceValue } from '@deepseek-ai/dsh-api-workspace-controller/types' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$6469726563746f72795069636b6572 { + createDirectory: (path: string, name: string) => Promise> + list: (path: string | undefined, signal?: AbortSignal) => Promise> + pick: (signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespace$776f726b7370616365 { + archiveSession: (request: WorkspaceArchiveSessionRequest) => Promise> + create: (request: WorkspaceCreateRequest) => Promise> + delete: (request: WorkspaceDeleteRequest) => Promise> + follow: (signal?: AbortSignal) => RemoteStreamHandle + initializeDefault: (signal?: AbortSignal) => Promise> + insertBefore: (request: WorkspaceInsertBeforeRequest) => Promise> + insertSessionBefore: (request: WorkspaceInsertSessionBeforeRequest) => Promise> + pinSession: (request: WorkspacePinSessionRequest) => Promise> + rename: (request: WorkspaceRenameRequest) => Promise> + unarchiveSession: (request: WorkspaceUnarchiveSessionRequest) => Promise> + unpinSession: (request: WorkspaceUnpinSessionRequest) => Promise> + } + interface TypertRemoteMap { + 'directoryPicker/createDirectory': (path: string, name: string) => Promise> + 'directoryPicker/list': (path: string | undefined, signal?: AbortSignal) => Promise> + 'directoryPicker/pick': (signal?: AbortSignal) => Promise> + 'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest) => Promise> + 'workspace/create': (request: WorkspaceCreateRequest) => Promise> + 'workspace/delete': (request: WorkspaceDeleteRequest) => Promise> + 'workspace/follow': (signal?: AbortSignal) => RemoteStreamHandle + 'workspace/initializeDefault': (signal?: AbortSignal) => Promise> + 'workspace/insertBefore': (request: WorkspaceInsertBeforeRequest) => Promise> + 'workspace/insertSessionBefore': (request: WorkspaceInsertSessionBeforeRequest) => Promise> + 'workspace/pinSession': (request: WorkspacePinSessionRequest) => Promise> + 'workspace/rename': (request: WorkspaceRenameRequest) => Promise> + 'workspace/unarchiveSession': (request: WorkspaceUnarchiveSessionRequest) => Promise> + 'workspace/unpinSession': (request: WorkspaceUnpinSessionRequest) => Promise> + } + interface TypertRemoteNamespaceMap { + 'directoryPicker': TypertRemoteNamespace$6469726563746f72795069636b6572 + 'workspace': TypertRemoteNamespace$776f726b7370616365 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/workspace-controller/generated/remote.js b/apps/web-ui/packages/api/workspace-controller/generated/remote.js new file mode 100644 index 000000000..b7a1babd5 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/generated/remote.js @@ -0,0 +1,521 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema$value ??= z.union([z.undefined(), z.string()])) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema$value ??= z.object({ + 'path': z.string(), + 'home': z.string(), + 'crumbs': z.array(z.object({ + 'name': z.string(), + 'path': z.string(), + 'hidden': z.boolean(), +})), + 'entries': z.array(z.object({ + 'name': z.string(), + 'path': z.string(), + 'hidden': z.boolean(), +})), + 'truncated': z.boolean(), +})) +let _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema$value ??= z.union([z.literal(null), z.string()])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'stopActivity': z.boolean().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema$value ??= z.object({ + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema$value ??= z.object({ + 'path': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), + 'created': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema$value ??= z.object({ + 'deleted': z.literal(true).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema$value ??= z.union([z.object({ + 'type': z.literal("baseline").readonly(), + 'value': z.object({ + 'items': z.array(z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +})).readonly(), + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}).readonly(), +}), z.object({ + 'type': z.literal("upsert").readonly(), + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +}), z.object({ + 'type': z.literal("remove").readonly(), + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), +}), z.object({ + 'type': z.literal("order").readonly(), + 'workspaceIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}), z.object({ + 'type': z.literal("archived").readonly(), + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +}), z.object({ + 'type': z.literal("pinned").readonly(), + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema$value ??= z.union([z.undefined(), z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'beforeWorkspaceId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema$value ??= z.object({ + 'workspaceIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), + 'beforeSessionId': z.intersection(z.string(), z.unknown()).readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema$value ??= z.object({ + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema$value ??= z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'title': z.string().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema$value ??= z.object({ + 'workspace': z.object({ + 'workspaceId': z.intersection(z.string(), z.unknown()).readonly(), + 'path': z.string().readonly(), + 'title': z.string().readonly(), + 'sessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), + 'createdAt': z.string().readonly(), + 'updatedAt': z.string().readonly(), +}).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema$value ??= z.object({ + 'archivedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema$value ??= z.object({ + 'sessionId': z.intersection(z.string(), z.unknown()).readonly(), +})) +let _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema$value +const _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema = () => (_deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema$value ??= z.object({ + 'pinnedSessionIds': z.array(z.intersection(z.string(), z.unknown())).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-workspace-controller', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'createDirectory', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:path', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_0$schema, + }, + }, + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:name', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/createDirectory:result', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_createDirectory_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":88,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/list', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'path', + wire: 'path', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/list:path', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_parameter_0$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-host-directory-picker/types#DirectoryListing', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_list_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":72,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/pick', + service: 'directoryPickerController', + namespace: 'directoryPicker', + method: 'pick', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#directoryPicker/pick:result', + create: _deepseek_ai_dsh_api_workspace_controller_directoryPicker_pick_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/directory-picker.ts","line":55,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/archiveSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'archiveSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_archiveSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":155,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/create', + service: 'workspaceController', + namespace: 'workspace', + method: 'create', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceCreateRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_create_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceCreateValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_create_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":86,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/delete', + service: 'workspaceController', + namespace: 'workspace', + method: 'delete', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceDeleteRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_delete_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceDeleteValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_delete_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":125,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/follow', + service: 'workspaceController', + namespace: 'workspace', + method: 'follow', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceFollowFrame', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_follow_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":195,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/initializeDefault', + service: 'workspaceController', + namespace: 'workspace', + method: 'initializeDefault', + invocation: { kind: 'direct' }, + parameters: [ + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller#workspace/initializeDefault:result', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_initializeDefault_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":99,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/insertBefore', + service: 'workspaceController', + namespace: 'workspace', + method: 'insertBefore', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceInsertBeforeRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceOrderValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertBefore_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":135,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/insertSessionBefore', + service: 'workspaceController', + namespace: 'workspace', + method: 'insertSessionBefore', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceInsertSessionBeforeRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_insertSessionBefore_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":145,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/pinSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'pinSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_pinSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":175,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/rename', + service: 'workspaceController', + namespace: 'workspace', + method: 'rename', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceRenameRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_rename_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_rename_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":115,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/unarchiveSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'unarchiveSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceUnarchiveSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceArchiveValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unarchiveSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":165,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-controller#workspace/unpinSession', + service: 'workspaceController', + namespace: 'workspace', + method: 'unpinSession', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspaceUnpinSessionRequest', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-controller/types#WorkspacePinValue', + create: _deepseek_ai_dsh_api_workspace_controller_workspace_unpinSession_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-controller/src/index.ts","line":185,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/workspace-controller/package.json b/apps/web-ui/packages/api/workspace-controller/package.json new file mode 100644 index 000000000..741cbb8b0 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-controller", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./default-workspace": { + "types": "./src/default-workspace.ts", + "default": "./src/default-workspace.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-api-gateway": "*", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-client-store": "*", + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/index.ts b/apps/web-ui/packages/api/workspace-controller/src/client/index.ts new file mode 100644 index 000000000..a16533430 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/index.ts @@ -0,0 +1,143 @@ +/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from "@deepseek-ai/dsh-api-gateway/client"; +import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from "../types.ts"; +import type { WorkspaceFollowSink } from "./model.ts"; +import { ClientWorkspaceModel } from "./model.ts"; +import { WorkspaceController } from "./service.ts"; + +export { ClientWorkspaceModel } from "./model.ts"; +export type { + WorkspaceFollowSink, + WorkspaceListPhase, + WorkspaceRemote, + WorkspaceSnapshot, +} from "./model.ts"; +export { WorkspaceArchiveError, WorkspaceController, WorkspaceCreateError } from "./service.ts"; +export type { IWorkspaces, WorkspaceSource } from "./service.ts"; +export type { + SessionActivity, + SessionActivityItem, + SessionActivityKind, + SessionActivityKindMap, + WorkspaceId, + WorkspaceView, +} from "../types.ts"; + +type WorkspaceBaselineFrame = Extract; + +/** Gateway-owned snapshot stream configured for Workspace state. */ +export type WorkspaceStateStream = RemoteSnapshotStream< + WorkspaceBaselineFrame, + WorkspaceFollowIncrement +>; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** React-free Client Workspace state and commands. */ + workspaces: import("./service.ts").IWorkspaces; + } +} + +/** Required Client Remote services. */ +export const inject = ["remote", "remote.workspace"]; + +/** + * Install Client Workspace state, commands, and reconnecting follow control. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const model = new ClientWorkspaceModel(ctx.remote.workspace); + new WorkspaceController(ctx, model); + const control = createWorkspaceStateStream(ctx.remote, { + accept: model, + carrierFailed: () => { + model.handleCarrierFailure(); + }, + failed: (error) => { + model.handleStreamFailure(error); + }, + }); + control.start(); + ctx.effect( + () => async () => { + await control.dispose(); + }, + "workspace-controller.client.control", + ); +} + +/** Domain sinks used by the Workspace state stream. */ +export interface WorkspaceStateStreamOptions { + /** Destinations for decoded Workspace state operations. */ + readonly accept: WorkspaceFollowSink; + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void; + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void; +} + +/** + * Create the reconnecting Workspace state stream. + * @param remote - Client Remote face carrying the Workspace namespace and the stream factory. + * @param options - Workspace state destinations. + * @returns an unstarted stream owned by the Client Workspace runtime. + */ +export function createWorkspaceStateStream( + remote: ClientRemote, + options: WorkspaceStateStreamOptions, +): WorkspaceStateStream { + const stream = remote.$stream({ + name: "Workspace state stream", + open: (signal) => remote.workspace.follow(signal), + ended: (accepted) => + accepted + ? new RemoteStreamCarrierError("Workspace state stream ended without a terminal result") + : new Error("Workspace state stream ended before its opening snapshot"), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }); + return new RemoteSnapshotStream(stream, { + name: "Workspace state stream", + isSnapshot: (frame): frame is WorkspaceBaselineFrame => frame.type === "baseline", + replace: (frame) => { + options.accept.replaceBaseline(frame.value); + }, + update: (frame) => { + acceptIncrement(options.accept, frame); + }, + failed: options.failed, + }); +} + +function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { + switch (frame.type) { + case "upsert": + accept.upsertView(frame.workspace); + return; + case "remove": + accept.removeView(frame.workspaceId); + return; + case "order": + accept.replaceOrder(frame.workspaceIds); + return; + case "archived": + accept.replaceArchived(frame.archivedSessionIds); + return; + case "pinned": + accept.replacePinned(frame.pinnedSessionIds); + return; + /* v8 ignore next -- the generated Remote codec validates this closed union */ + default: + return assertNever(frame); + } +} + +/* v8 ignore next 3 -- closed-union backstop after generated Remote validation */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace increment: ${JSON.stringify(value)}`); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/model.ts b/apps/web-ui/packages/api/workspace-controller/src/client/model.ts new file mode 100644 index 000000000..ecf24476a --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/model.ts @@ -0,0 +1,485 @@ +/** Client-side Workspace state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from "@deepseek-ai/dsh-client-store"; +import type {} from "@deepseek-ai/dsh-api-workspace-controller/remote"; +import { isRemoteFailure } from "@deepseek-ai/dsh-api-gateway/client"; +import type { + RemoteFailure, + RemoteResult, + TypertClientRemote, +} from "@deepseek-ai/dsh-typert-protocol"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceBaseline, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteValue, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, + WorkspaceId, + WorkspaceView, +} from "../types.ts"; + +/** Complete generated `ctx.remote.workspace` namespace. */ +export type WorkspaceRemote = TypertClientRemote["workspace"]; + +/** Monotone Workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = "pending" | "ready"; + +/** Immutable Client Workspace state. */ +export interface WorkspaceSnapshot { + readonly items: readonly WorkspaceView[]; + /** Complete registry-global archive set in Host order. */ + readonly archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]; + /** Complete registry-global pin set, most recently pinned first. */ + readonly pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]; + readonly state: "idle" | "loading" | "error"; + readonly phase: WorkspaceListPhase; + readonly error: RemoteFailure | null; +} + +/** State operations emitted by a decoded Workspace follow generation. */ +export interface WorkspaceFollowSink { + /** Replace all state from the generation baseline. */ + replaceBaseline(value: WorkspaceBaseline): void; + /** Merge one Workspace row. */ + upsertView(workspace: WorkspaceView): void; + /** Remove one Workspace row. */ + removeView(workspaceId: WorkspaceId): void; + /** Replace the Host-confirmed Workspace order. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void; + /** Replace the complete archived Session set. */ + replaceArchived(sessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void; + /** Replace the complete pinned Session set. */ + replacePinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void; +} + +/** + * Owns the Client Workspace projection, mutation echoes, and stream/unary race resolution. + */ +export class ClientWorkspaceModel implements WorkspaceFollowSink { + private items: readonly WorkspaceView[] = []; + private archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"] = []; + private pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"] = []; + private state: WorkspaceSnapshot["state"] = "loading"; + private phase: WorkspaceListPhase = "pending"; + private error: RemoteFailure | null = null; + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0; + /** Increments on stream orders so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0; + /** Last complete order accepted from a baseline, increment, or current unary echo. */ + private committedOrder: WorkspaceId[] = []; + /** Latest archive-set request; a later request or a pushed set supersedes it. */ + private archiveRequestSeq = 0; + /** Latest pin-set request; a later request or a pushed set supersedes it. */ + private pinRequestSeq = 0; + /** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */ + private readonly removedIds = new Set(); + private readonly listeners = new Set<() => void>(); + private snapshotCache: WorkspaceSnapshot; + private snapshotDirty = false; + private notificationPending = false; + private notificationScheduled = false; + private notificationGeneration = 0; + + /** @param remote - generated Workspace Remote namespace. */ + constructor(private readonly remote: WorkspaceRemote) { + this.snapshotCache = this.buildSnapshot(); + } + + /** + * Create or resolve a Workspace and merge the unary result immediately. + * @param input - existing absolute path to adopt. + * @returns generated Remote result. + */ + async create(input: WorkspaceCreateRequest): Promise> { + const result = await this.remote.create(input); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Initialize the default Workspace and merge its authoritative row. + * @param signal - caller lifetime. + * @returns generated Remote result. + */ + async initializeDefault(signal?: AbortSignal): Promise> { + const result = await this.remote.initializeDefault(signal); + if (result.ok && result.value !== undefined) this.upsert(result.value.workspace); + return result; + } + + /** + * Rename a Workspace and merge the unary result immediately. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns generated Remote result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const result = await this.remote.rename({ workspaceId, title }); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Delete a Workspace and remove it from the local projection immediately. + * @param workspaceId - target Workspace. + * @returns generated Remote result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const result = await this.remote.delete({ workspaceId }); + if (result.ok) this.remove(workspaceId, true); + return result; + } + + /** + * Optimistically move a Workspace and reconcile the returned complete order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + * @returns generated Remote result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration; + const frameGeneration = this.orderFrameGeneration; + const localOrder = this.items.map((workspace) => workspace.workspaceId); + this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)); + const result = await this.remote.insertBefore({ + workspaceId, + ...(beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }), + }); + if ( + requestGeneration === this.orderRequestGeneration && + frameGeneration === this.orderFrameGeneration + ) { + this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok); + } + return result; + } + + /** + * Move a Session within its Workspace and merge the returned row. + * @param workspaceId - owning Workspace. + * @param sessionId - accounted Session to move. + * @param beforeSessionId - accounted anchor; omitted appends. + * @returns generated Remote result. + */ + async insertSessionBefore( + workspaceId: WorkspaceInsertSessionBeforeRequest["workspaceId"], + sessionId: WorkspaceInsertSessionBeforeRequest["sessionId"], + beforeSessionId?: WorkspaceInsertSessionBeforeRequest["beforeSessionId"], + ): Promise> { + const result = await this.remote.insertSessionBefore({ + workspaceId, + sessionId, + ...(beforeSessionId === undefined ? {} : { beforeSessionId }), + }); + if (result.ok) this.upsert(result.value.workspace); + return result; + } + + /** + * Archive one Session and install the returned complete archive set. + * A reply superseded by a later archive request or a pushed set installs nothing. + * @param sessionId - Session to archive. + * @param options - Whether the Host stops the Session's running work instead of refusing. + * @returns generated Remote result. + */ + async archiveSession( + sessionId: WorkspaceArchiveSessionRequest["sessionId"], + options: Pick = {}, + ): Promise> { + const requestSeq = ++this.archiveRequestSeq; + const result = await this.remote.archiveSession({ + sessionId, + ...(options.stopActivity === true ? { stopActivity: true } : {}), + }); + if (result.ok && requestSeq === this.archiveRequestSeq) { + this.installArchived(result.value.archivedSessionIds); + // The Host drops an archived session's pin in the same durable write; + // mirror that locally so no frame shows the row both archived and pinned. + this.installPinned(this.pinnedSessionIds.filter((id) => id !== sessionId)); + } + return result; + } + + /** + * Unarchive one Session and install the returned complete archive set. + * A reply superseded by a later archive request or a pushed set installs nothing. + * @param sessionId - Session to unarchive. + * @returns generated Remote result. + */ + async unarchiveSession( + sessionId: WorkspaceUnarchiveSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.archiveRequestSeq; + const result = await this.remote.unarchiveSession({ sessionId }); + if (result.ok && requestSeq === this.archiveRequestSeq) { + this.installArchived(result.value.archivedSessionIds); + } + return result; + } + + /** + * Pin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to pin. + * @returns generated Remote result. + */ + async pinSession( + sessionId: WorkspacePinSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.pinRequestSeq; + const result = await this.remote.pinSession({ sessionId }); + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessionIds); + } + return result; + } + + /** + * Unpin one Session and install the returned complete pin set. + * A reply superseded by a later pin request or a pushed set installs nothing. + * @param sessionId - Session to unpin. + * @returns generated Remote result. + */ + async unpinSession( + sessionId: WorkspaceUnpinSessionRequest["sessionId"], + ): Promise> { + const requestSeq = ++this.pinRequestSeq; + const result = await this.remote.unpinSession({ sessionId }); + if (result.ok && requestSeq === this.pinRequestSeq) { + this.installPinned(result.value.pinnedSessionIds); + } + return result; + } + + /** + * Replace the projection from one complete stream-generation baseline. + * @param baseline - complete Workspace and archive projection. + */ + replaceBaseline(baseline: WorkspaceBaseline): void { + this.orderFrameGeneration++; + this.archiveRequestSeq++; + this.pinRequestSeq++; + this.installViews(baseline.items); + this.installArchived(baseline.archivedSessionIds); + this.installPinned(baseline.pinnedSessionIds); + this.state = "idle"; + this.phase = "ready"; + this.error = null; + this.invalidate(); + } + + /** Merge one decoded Workspace upsert from the current follow generation. */ + upsertView(workspace: WorkspaceView): void { + this.upsert(workspace); + } + + /** Apply one decoded Workspace removal from the current follow generation. */ + removeView(workspaceId: WorkspaceId): void { + this.remove(workspaceId); + } + + /** Replace Host-confirmed order from the current follow generation. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void { + this.orderFrameGeneration++; + this.installOrder(workspaceIds, true); + } + + /** + * Replace the archived Session set from the current follow generation. + * @param archivedSessionIds - complete Host-confirmed archive set. + */ + replaceArchived(archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void { + this.archiveRequestSeq++; + this.installArchived(archivedSessionIds); + } + + /** + * Replace the pinned Session set from the current follow generation. + * @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first. + */ + replacePinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void { + this.pinRequestSeq++; + this.installPinned(pinnedSessionIds); + } + + /** Keep the last complete projection visible while a lost carrier reconnects. */ + handleCarrierFailure(): void { + this.state = "loading"; + this.error = null; + this.invalidate(); + } + + /** + * Publish a non-retryable stream or protocol failure. + * @param error - terminal stream failure. + */ + handleStreamFailure(error: unknown): void { + if (!isRemoteFailure(error)) throw error; + this.state = "error"; + this.error = error; + this.invalidate(); + } + + /** + * Subscribe to Workspace state invalidation. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Read the cached state, rebuilding it first when necessary. + * @returns the current stable Workspace list snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.refreshSnapshot(); + return this.snapshotCache; + } + + private buildSnapshot(): WorkspaceSnapshot { + return { + items: this.items, + archivedSessionIds: this.archivedSessionIds, + pinnedSessionIds: this.pinnedSessionIds, + state: this.state, + phase: this.phase, + error: this.error, + }; + } + + private installArchived(archivedSessionIds: WorkspaceArchiveValue["archivedSessionIds"]): void { + if ( + archivedSessionIds.length === this.archivedSessionIds.length && + archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index]) + ) + return; + this.archivedSessionIds = [...archivedSessionIds]; + this.invalidate(); + } + + private installPinned(pinnedSessionIds: WorkspacePinValue["pinnedSessionIds"]): void { + if ( + pinnedSessionIds.length === this.pinnedSessionIds.length && + pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index]) + ) + return; + this.pinnedSessionIds = [...pinnedSessionIds]; + this.invalidate(); + } + + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { + if (committed) this.committedOrder = [...workspaceIds]; + const rank = new Map(workspaceIds.map((id, index) => [id, index])); + const items = [...this.items].sort( + (left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER), + ); + if (items.every((item, index) => item === this.items[index])) return; + this.items = items; + this.invalidate(); + } + + private upsert(view: WorkspaceView): void { + if (this.removedIds.has(view.workspaceId)) return; + const index = this.items.findIndex((item) => item.workspaceId === view.workspaceId); + const installed = this.items[index]; + // Unary responses and stream increments race on separate requests. Keep + // the newest Host projection regardless of their arrival order. + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) + return; + if (!this.committedOrder.includes(view.workspaceId)) { + this.committedOrder = [view.workspaceId, ...this.committedOrder]; + } + this.items = + index === -1 + ? [view, ...this.items] + : this.items.map((item, position) => (position === index ? view : item)); + this.invalidate(); + } + + private remove(workspaceId: WorkspaceId, immediate = false): void { + this.removedIds.add(workspaceId); + this.committedOrder = this.committedOrder.filter((id) => id !== workspaceId); + const items = this.items.filter((item) => item.workspaceId !== workspaceId); + if (items.length === this.items.length) { + // A successful unary echo still publishes an earlier increment's + // pending removal before the user operation resolves. + if (immediate) this.invalidate(true); + return; + } + this.items = items; + this.invalidate(immediate); + } + + private installViews(views: readonly WorkspaceView[]): void { + const installed = new Map(); + for (const view of views) { + if (!this.removedIds.has(view.workspaceId)) installed.set(view.workspaceId, view); + } + this.items = [...installed.values()]; + this.committedOrder = views.map((view) => view.workspaceId); + } + + private invalidate(immediate = false): void { + this.snapshotDirty = true; + this.notificationPending = true; + if (immediate) { + this.notificationGeneration++; + this.notificationScheduled = false; + this.flush(); + return; + } + if (this.notificationScheduled) return; + this.notificationScheduled = true; + const generation = ++this.notificationGeneration; + queueMicrotask(() => { + if (generation !== this.notificationGeneration) return; + this.notificationScheduled = false; + this.flush(); + }); + } + + private flush(): void { + if (!this.notificationPending || this.listeners.size === 0) return; + this.notificationPending = false; + this.refreshSnapshot(); + notifySubscribers(this.listeners, "[workspace-controller]"); + } + + private refreshSnapshot(): void { + if (!this.snapshotDirty) return; + this.snapshotDirty = false; + this.snapshotCache = this.buildSnapshot(); + } +} + +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids]; + } + const without = ids.filter((candidate) => candidate !== id); + const at = beforeId === undefined ? without.length : without.indexOf(beforeId); + return [...without.slice(0, at), id, ...without.slice(at)]; +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/client/service.ts b/apps/web-ui/packages/api/workspace-controller/src/client/service.ts new file mode 100644 index 000000000..4fce10e92 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/client/service.ts @@ -0,0 +1,200 @@ +/** React-free Client Workspace service and command facade. */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { RemoteFailure } from "@deepseek-ai/dsh-typert-protocol"; +import type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +import type { WorkspaceView } from "../types.ts"; +import type { ClientWorkspaceModel, WorkspaceSnapshot } from "./model.ts"; + +/** Structured create failure for callers that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + override readonly name = "WorkspaceCreateError"; + + /** @param rpcError - Host business or folded carrier failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** + * Archive failed on the Host. `rpcError.code` distinguishes the active-session + * refusal (`workspace/session-active`, whose details name what still runs) + * from a missing session or a carrier fault. + */ +export class WorkspaceArchiveError extends Error { + override readonly name = "WorkspaceArchiveError"; + + /** @param rpcError - Host business or folded carrier failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace session archive failed: ${rpcError.code}: ${rpcError.message}`); + } +} + +/** Bare observable source for the Workspace Controller snapshot. */ +export interface WorkspaceSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): WorkspaceSnapshot; + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** Workspace Controller's Client service face. */ +export interface IWorkspaces { + /** Host-authoritative Workspace rows, order, archive set, and follow lifecycle. */ + readonly list: WorkspaceSource; + /** + * Register an existing path as a Workspace. + * @param input - Host create payload. + * @returns the created or idempotently resolved Workspace. + */ + create(input: { path: string }): Promise; + /** + * Initialize or reuse the default Workspace. + * @param signal - caller lifetime. + * @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure. + */ + initializeDefault(signal?: AbortSignal): Promise; + /** + * Rename a Workspace. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns the renamed Workspace. + */ + rename(workspaceId: WorkspaceId, title: string): Promise; + /** + * Delete a Workspace registration without deleting Sessions or files. + * @param workspaceId - target Workspace. + */ + delete(workspaceId: WorkspaceId): Promise; + /** + * Move a Workspace within the Host registry order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise; + /** + * Archive a Session from Workspace grouping surfaces. + * @param sessionId - Session to archive. + * @param options - `stopActivity` asks the Host to stop the Session's running work instead of refusing. + * @throws {WorkspaceArchiveError} when the Host refuses; without `stopActivity` a Session with + * running work fails as `workspace/session-active`, its details naming what runs. + */ + archiveSession( + sessionId: SessionId, + options?: { readonly stopActivity?: boolean }, + ): Promise; + /** + * Unarchive a Session from the archived Session list. + * @param sessionId - Session to unarchive. + */ + unarchiveSession(sessionId: SessionId): Promise; + /** + * Pin a Session ahead of unpinned Sessions on Workspace grouping surfaces. + * @param sessionId - Session to pin. + */ + pinSession(sessionId: SessionId): Promise; + /** + * Remove a Session's pin without changing its saved Session order. + * @param sessionId - Session to unpin. + */ + unpinSession(sessionId: SessionId): Promise; + /** + * Move a Session within one Workspace account. + * @param workspaceId - owning Workspace. + * @param sessionId - Session to move. + * @param beforeSessionId - anchor Session; omitted appends. + * @returns the changed Workspace. + */ + insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise; +} + +/** Owns the bare Workspace snapshot and Workspace-only commands. */ +export class WorkspaceController extends Service implements IWorkspaces { + readonly list: WorkspaceSource; + + /** + * @param ctx - Client root Context. + * @param model - Remote-backed Workspace state model. + */ + constructor( + ctx: Context, + private readonly model: ClientWorkspaceModel, + ) { + super(ctx, "workspaces"); + this.list = model; + } + + async create(input: { path: string }): Promise { + const result = await this.model.create(input); + if (!result.ok) throw new WorkspaceCreateError(result.error); + return result.value.workspace; + } + + async initializeDefault(signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(signal); + if (!result.ok) throw new WorkspaceCreateError(result.error); + return result.value?.workspace; + } + + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.model.rename(workspaceId, title); + if (!result.ok) throw commandError("rename", result.error); + return result.value.workspace; + } + + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.model.delete(workspaceId); + if (!result.ok) throw commandError("delete", result.error); + } + + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId); + if (!result.ok) throw commandError("reorder", result.error); + } + + async archiveSession( + sessionId: SessionId, + options: { readonly stopActivity?: boolean } = {}, + ): Promise { + const result = await this.model.archiveSession(sessionId, options); + if (!result.ok) throw new WorkspaceArchiveError(result.error); + } + + async unarchiveSession(sessionId: SessionId): Promise { + const result = await this.model.unarchiveSession(sessionId); + if (!result.ok) throw commandError("session unarchive", result.error); + } + + async pinSession(sessionId: SessionId): Promise { + const result = await this.model.pinSession(sessionId); + if (!result.ok) throw commandError("session pin", result.error); + } + + async unpinSession(sessionId: SessionId): Promise { + const result = await this.model.unpinSession(sessionId); + if (!result.ok) throw commandError("session unpin", result.error); + } + + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId); + if (!result.ok) throw commandError("move", result.error); + return result.value.workspace; + } +} + +function commandError(operation: string, failure: RemoteFailure): Error { + return new Error(`workspace ${operation} failed: ${failure.code}: ${failure.message}`); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/commands.ts b/apps/web-ui/packages/api/workspace-controller/src/commands.ts new file mode 100644 index 000000000..a5b725045 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/commands.ts @@ -0,0 +1,270 @@ +/** Workspace command implementation and stable Remote failure mapping. */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { Workspace } from "@deepseek-ai/dsh-workspace"; +import { + WorkspaceActiveSessionError, + WorkspaceArchivedSessionPinError, + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, + WorkspaceUnknownSessionError, +} from "@deepseek-ai/dsh-workspace"; +import { RemoteError, remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import { workspaceView } from "./feed.ts"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceRenameRequest, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, +} from "./types.ts"; + +/** Implements Workspace mutations against the authoritative registry. */ +export class WorkspaceCommands { + private operationTail = Promise.resolve(); + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(private readonly ctx: Context) {} + + /** + * Create or resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + create(request: WorkspaceCreateRequest): Promise { + return this.enqueue(async () => { + try { + const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path); + if (existing !== undefined) { + return { workspace: workspaceView(existing), created: false }; + } + const workspace = await this.ctx.workspaceRegistry.create(request.path); + return { workspace: workspaceView(workspace), created: true }; + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error; + throw new RemoteError( + "workspace/invalid-path", + `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, + { path: request.path }, + { cause: error }, + ); + } + }); + } + + /** + * Rename one Workspace after serializing title ownership checks. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + rename(request: WorkspaceRenameRequest): Promise { + const title = request.title.trim(); + if (title === "") { + return Promise.reject( + new RemoteError("gateway/bad-request", "Workspace rename requires a non-blank title", {}), + ); + } + return this.enqueue(async () => { + const workspace = this.requireWorkspace(request.workspaceId); + if (title !== workspace.title) { + if ( + this.ctx.workspaceRegistry + .list() + .some((candidate) => candidate.id !== workspace.id && candidate.title === title) + ) { + throw new RemoteError( + "workspace/name-conflict", + `Workspace name '${title}' is already in use`, + { name: title }, + ); + } + await workspace.setTitle(title); + } + return { workspace: workspaceView(workspace) }; + }); + } + + /** + * Delete one Workspace registration without deleting its directory or Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + delete(request: WorkspaceDeleteRequest): Promise { + return this.enqueue(async () => { + if (!(await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId)))) { + throw workspaceNotFound(request.workspaceId); + } + return { deleted: true }; + }); + } + + /** + * Move one Workspace within the durable registry order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + async insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + try { + const workspaceIds = await this.ctx.workspaceRegistry.insertBefore( + WorkspaceId(request.workspaceId), + request.beforeWorkspaceId === undefined + ? undefined + : WorkspaceId(request.beforeWorkspaceId), + ); + return { workspaceIds: [...workspaceIds] }; + } catch (error) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error; + throw workspaceNotFound(error.workspaceId); + } + } + + /** + * Move one accounted Session within a Workspace's manual order. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + const workspace = this.requireWorkspace(request.workspaceId); + try { + await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId); + } catch (error) { + if (!(error instanceof WorkspaceMoveInvalidError)) throw error; + throw new RemoteError( + "workspace/move-invalid", + error.message, + { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...(request.beforeSessionId === undefined + ? {} + : { beforeSessionId: request.beforeSessionId }), + }, + { cause: error }, + ); + } + return { workspace: workspaceView(workspace) }; + } + + /** + * Add one known Session to the registry-global archive set. Without + * `stopActivity` a Session with running work is refused as + * `workspace/session-active` with the activity the registry's providers + * reported; with it, the providers stop that work first. + * @param request - Session identity to archive and whether to stop its work. + * @returns the complete resulting archive set. + */ + async archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.archiveSession( + request.sessionId, + request.stopActivity === true ? { stopActivity: true } : {}, + ); + } catch (error) { + if (error instanceof WorkspaceUnknownSessionError) { + throw new RemoteError( + "session/not-found", + error.message, + { sessionId: request.sessionId }, + { cause: error }, + ); + } + if (error instanceof WorkspaceActiveSessionError) { + throw new RemoteError( + "workspace/session-active", + error.message, + { sessionId: request.sessionId, activity: error.activity }, + { cause: error }, + ); + } + throw error; + } + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }; + } + + /** + * Drop one Session from the registry-global archive set. An id that is not + * archived is not an error: the call is idempotent, so a lost race with + * another surface resolves as a no-op. + * @param request - Session identity to unarchive. + * @returns the complete resulting archive set. + */ + async unarchiveSession( + request: WorkspaceUnarchiveSessionRequest, + ): Promise { + await this.ctx.workspaceRegistry.unarchiveSession(request.sessionId); + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }; + } + + /** + * Add one known unarchived Session to the registry-global pin set. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async pinSession(request: WorkspacePinSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.pinSession(request.sessionId); + } catch (error) { + if (error instanceof WorkspaceUnknownSessionError) { + throw new RemoteError( + "session/not-found", + error.message, + { sessionId: request.sessionId }, + { cause: error }, + ); + } + if (error instanceof WorkspaceArchivedSessionPinError) { + throw new RemoteError("gateway/bad-request", error.message, {}, { cause: error }); + } + throw error; + } + return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }; + } + + /** + * Drop one Session from the registry-global pin set. An id that is not + * pinned is not an error: the call is idempotent, so a lost race with + * another surface resolves as a no-op. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + async unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + await this.ctx.workspaceRegistry.unpinSession(request.sessionId); + return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }; + } + + private requireWorkspace(workspaceId: WorkspaceId): Workspace { + const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId)); + if (workspace === undefined) throw workspaceNotFound(workspaceId); + return workspace; + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation); + this.operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<"workspace/not-found"> { + return new RemoteError("workspace/not-found", `Workspace "${workspaceId}" not found`, { + workspaceId, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts b/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts new file mode 100644 index 000000000..9792c6bbc --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/default-directory.ts @@ -0,0 +1,98 @@ +/** Resolve the Host account's Documents directory for first-use Workspace creation. */ + +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { runNativeCommand, type NativeCommandRunner } from "@deepseek-ai/dsh-native-command"; +import { DEFAULT_WORKSPACE_DIRECTORY } from "./default-workspace.ts"; + +/** Platform observations replaceable in directory-resolution tests. */ +interface DocumentsDirectoryInternals { + readonly platform?: NodeJS.Platform; + readonly home?: string; + readonly run?: NativeCommandRunner; +} + +/** + * Validate a configured or OS-returned Documents path without resolving it against cwd. + * @param directory - fully qualified directory spelling. + * @param platform - Host platform. + * @returns the normalized directory. + */ +export function validateDocumentsDirectory( + directory: string, + platform: NodeJS.Platform = process.platform, +): string { + const paths = platform === "win32" ? win32 : posix; + const root = paths.parse(directory).root; + if (!paths.isAbsolute(directory) || (platform === "win32" && (root === "\\" || root === "/"))) { + throw new Error(`Documents directory must be fully qualified: '${directory}'`); + } + return paths.normalize(directory); +} + +/** + * Resolve the first-use directory on the Host without creating files. + * @param documentsDirectory - explicit deployment override for the system Documents directory. + * @param signal - caller lifetime and lookup deadline. + * @param internals - platform facts and native command runner. + * @returns the absolute candidate path. + */ +export async function defaultWorkspaceDirectory( + documentsDirectory: string | undefined, + signal: AbortSignal, + internals: DocumentsDirectoryInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform; + const paths = platform === "win32" ? win32 : posix; + signal.throwIfAborted(); + let directory = documentsDirectory; + if (directory === undefined) { + const run = internals.run ?? runNativeCommand; + let stdout: string; + switch (platform) { + case "darwin": + ({ stdout } = await run( + "osascript", + [ + "-e", + "POSIX path of (path to documents folder from user domain without folder creation)", + ], + signal, + "hidden", + )); + break; + case "win32": + ({ stdout } = await run( + "powershell.exe", + [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); " + + "[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, " + + "[Environment+SpecialFolderOption]::DoNotVerify)", + ], + signal, + "hidden", + )); + break; + case "linux": + ({ stdout } = await run("xdg-user-dir", ["DOCUMENTS"], signal, "hidden")); + break; + default: + throw new Error(`system Documents directory is unavailable on ${platform}`); + } + directory = stdout.replace(/[\r\n]+$/, ""); + // XDG reports the home directory when this user directory is disabled. + if ( + directory === "" || + (platform === "linux" && paths.normalize(directory) === (internals.home ?? homedir())) + ) { + throw new Error("system Documents directory is unavailable"); + } + } + directory = validateDocumentsDirectory(directory, platform); + signal.throwIfAborted(); + return paths.join(directory, "deepseek-harness", DEFAULT_WORKSPACE_DIRECTORY); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts b/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts new file mode 100644 index 000000000..e1373cc04 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/default-workspace.ts @@ -0,0 +1,30 @@ +/** + * Fixed first-use Workspace naming, shared by the Host that creates the + * directory and by browser consumers that label the resulting row. A pure fold + * with no imports, so client bundles inline it instead of requesting a + * module-table row this package does not publish. + * @module @deepseek-ai/dsh-api-workspace-controller/default-workspace + */ + +/** + * Leaf directory name of the first-use Workspace under + * `/deepseek-harness`. Language-neutral, so one installation keeps + * one on-disk path across language switches. The registry derives the initial + * title from this same segment, which is the title + * {@link workspaceDisplayTitle} recognizes as automatic. + */ +export const DEFAULT_WORKSPACE_DIRECTORY = "default-workspace"; + +/** + * The text one Workspace is labeled with. A Workspace still carrying the + * automatic first-use title reads as the caller's localized default name; every + * other title reads verbatim in every language. A title the user typed as + * exactly {@link DEFAULT_WORKSPACE_DIRECTORY} is labeled as the default too; + * nothing else depends on the distinction. + * @param title - stored Workspace title. + * @param localizedDefault - the default Workspace name in the active language. + * @returns the title to display. + */ +export function workspaceDisplayTitle(title: string, localizedDefault: string): string { + return title === DEFAULT_WORKSPACE_DIRECTORY ? localizedDefault : title; +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts b/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 000000000..c594f7562 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/directory-picker.ts @@ -0,0 +1,188 @@ +/** + * Host directory-picking Remote owner: capability gating, cancellation, and the + * stable wire failure vocabulary over the `ctx.directoryPicker` seam. + */ + +import { Context } from "@deepseek-ai/cordis"; +import { z } from "zod"; +import { DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker"; +import type { + DirectoryPickerCapabilities, + DirectoryPickerErrorCode, +} from "@deepseek-ai/dsh-host-directory-picker"; +// The seam owns the listing declaration; the generator requires the reference +// site to name that package rather than this package's re-export of it. +import type { DirectoryListing } from "@deepseek-ai/dsh-host-directory-picker/types"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteErrorCode } from "@deepseek-ai/dsh-typert-protocol"; + +const createDirectoryRequestSchema = z + .object({ + path: z.string(), + name: z.string(), + }) + .refine( + (request) => + request.name.trim() !== "" && + request.name !== "." && + request.name !== ".." && + !/[/\\]/.test(request.name), + { message: "host.createDirectory requires a single non-blank path segment name" }, + ); + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host directory-picking Remote namespace owner. */ + directoryPickerController: DirectoryPickerController; + } +} + +/** + * Host service backing the generated `ctx.remote.directoryPicker` namespace. The + * seam it exports is abstract and therefore never a Loader entry of its own, so + * this controller carries the wire verbs: one composed backend serves either the + * native chooser or the browse primitives, and a verb the composition cannot + * serve is refused rather than approximated. + */ +export class DirectoryPickerController extends TypertRemoteService { + static inject = ["directoryPicker"]; + + /** @param ctx - Host context carrying the composed directory-picking backend. */ + constructor(ctx: Context) { + super(ctx, "directoryPickerController", { namespace: "directoryPicker" }); + } + + /** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + @Remote("pick") + async pick(signal: AbortSignal): Promise { + const capability = this.requireCapability("native", "pick"); + try { + return await capability.pick(signal); + } catch (error: unknown) { + throw cancellableFailure( + error, + signal, + "directory picker was aborted", + "directory picker failed", + ); + } + } + + /** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ + @Remote("list") + async list(path: string | undefined, signal: AbortSignal): Promise { + const capability = this.requireCapability("browse", "list"); + try { + return await capability.list(path, signal); + } catch (error: unknown) { + throw cancellableFailure(error, signal, "directory listing was aborted"); + } + } + + /** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + @Remote("createDirectory") + async createDirectory(path: string, name: string): Promise { + const request = createDirectoryRequestSchema.safeParse({ path, name }); + if (!request.success) { + throw new RemoteError("gateway/bad-request", "invalid payload for host.createDirectory", { + issues: request.error.issues, + }); + } + const capability = this.requireCapability("browse", "createDirectory"); + try { + return await capability.createDirectory(request.data.path, request.data.name); + } catch (error: unknown) { + throw browseFailure(error); + } + } + + /** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */ + private requireCapability( + kind: Kind, + method: string, + ): DirectoryPickerCapabilities[Kind] { + const capability = this.ctx.directoryPicker.capability(); + if (capability.kind !== kind) { + throw new RemoteError( + "directory-picker/unavailable", + `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, + { capability: capability.kind }, + ); + } + return capability as DirectoryPickerCapabilities[Kind]; + } +} + +/** + * Wire code answered for each seam browse failure. The seam's closed codes are + * its own local vocabulary, so this controller owns the projection onto the + * `directory-picker/*` codes a Remote caller discriminates on. + */ +const BROWSE_FAILURE_CODES = { + "directory-unreadable": "directory-picker/unreadable", + "directory-exists": "directory-picker/exists", + "directory-create-failed": "directory-picker/create-failed", +} as const satisfies Record; + +/** + * Classify a browse-primitive rejection: the seam's own closed codes carry the + * path they are about, and anything else stays an infrastructure failure. + * @param error - the primitive's rejection. + * @returns the failure to throw across the Remote boundary. + */ +function browseFailure(error: unknown): RemoteError { + if (error instanceof DirectoryPickerError) { + return new RemoteError( + BROWSE_FAILURE_CODES[error.code], + error.message, + { path: error.path }, + { cause: error }, + ); + } + return new RemoteError("gateway/internal", errorMessage(error), {}, { cause: error }); +} + +/** + * Classify a cancellable primitive's rejection. An abort is the caller's own + * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled` + * before the business classification runs. + * @param error - the primitive's rejection. + * @param signal - the caller lifetime the primitive ran under. + * @param cancelled - operator-facing text for the abort outcome. + * @param failed - prefix for a non-seam failure, when the verb has no closed codes. + * @returns the failure to throw across the Remote boundary. + */ +function cancellableFailure( + error: unknown, + signal: AbortSignal, + cancelled: string, + failed?: string, +): RemoteError { + if (signal.aborted) return new RemoteError("gateway/cancelled", cancelled, {}, { cause: error }); + if (failed === undefined) return browseFailure(error); + return new RemoteError( + "gateway/internal", + `${failed}: ${errorMessage(error)}`, + {}, + { cause: error }, + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/feed.ts b/apps/web-ui/packages/api/workspace-controller/src/feed.ts new file mode 100644 index 000000000..905b282ff --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/feed.ts @@ -0,0 +1,190 @@ +/** Reconnect-safe Workspace baseline and increment producer. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import type { DomainChanged } from "@deepseek-ai/dsh-storage-domain"; +import type { Workspace, WorkspaceRecord } from "@deepseek-ai/dsh-workspace"; +import { workspaceDomainState, workspaceRecord, WorkspaceId } from "@deepseek-ai/dsh-workspace"; +import type { WorkspaceBaseline, WorkspaceFollowFrame, WorkspaceView } from "./types.ts"; + +/** + * Project one authoritative Workspace entity into its Remote value. + * @param workspace - authoritative registry entity. + * @returns detached Workspace projection for Remote consumers. + */ +export function workspaceView(workspace: Workspace): WorkspaceView { + return { + workspaceId: workspace.id, + path: workspace.path, + title: workspace.title, + sessionIds: [...workspace.sessionIds], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }; +} + +function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { + const record: WorkspaceRecord = workspaceRecord.parse(value); + return { + workspaceId: WorkspaceId(workspaceId), + path: record.path, + title: record.title, + sessionIds: [...record.sessionIds], + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +/** Owns Workspace domain observation and all active follow generations. */ +export class WorkspaceFeed { + private readonly followers = new Set(); + private knownIds: Set; + private order: readonly string[]; + private archived: readonly string[]; + private pinned: readonly string[]; + + /** @param ctx - Host context containing the authoritative Workspace registry. */ + constructor(private readonly ctx: Context) { + const baseline = ctx.workspaceRegistry.list(); + this.knownIds = new Set(baseline.map((workspace) => String(workspace.id))); + this.order = baseline.map((workspace) => String(workspace.id)); + this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String); + this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String); + ctx.on("domain/changed", (change: DomainChanged) => { + this.changed(change); + }); + ctx.effect( + () => () => { + for (const follower of this.followers) follower.close(); + this.followers.clear(); + }, + "workspace-controller.feed", + ); + } + + /** + * Read the complete current projection synchronously. + * @returns all active Workspaces plus archived and pinned Session identities. + */ + baseline(): WorkspaceBaseline { + return { + items: this.ctx.workspaceRegistry.list().map(workspaceView), + archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds], + pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds], + }; + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted(); + const follower = new WorkspaceFollower(); + this.followers.add(follower); + try { + yield { type: "baseline", value: this.baseline() }; + yield* follower.read(signal); + } finally { + this.followers.delete(follower); + follower.close(); + } + } + + private changed(change: DomainChanged): void { + if (change.domain !== "workspace") return; + if (change.table === "") { + if (change.operation !== "put") return; + const state = workspaceDomainState.parse(change.value); + const nextOrder = state.workspaceIds.map(String); + const orderChanged = !sameStrings(this.order, nextOrder); + for (const id of state.workspaceIds) { + if (this.knownIds.has(id)) continue; + const workspace = this.ctx.workspaceRegistry.get(id); + if (workspace === undefined) { + throw new Error(`committed Workspace registry references missing Workspace "${id}"`); + } + this.knownIds.add(id); + this.publish({ type: "upsert", workspace: workspaceView(workspace) }); + } + this.order = nextOrder; + if (orderChanged) this.publish({ type: "order", workspaceIds: [...state.workspaceIds] }); + const nextArchived = state.archivedSessionIds.map(String); + if (!sameStrings(this.archived, nextArchived)) { + this.archived = nextArchived; + this.publish({ type: "archived", archivedSessionIds: [...state.archivedSessionIds] }); + } + const nextPinned = state.pinnedSessionIds.map(String); + if (!sameStrings(this.pinned, nextPinned)) { + this.pinned = nextPinned; + this.publish({ type: "pinned", pinnedSessionIds: [...state.pinnedSessionIds] }); + } + return; + } + if (change.table !== "workspaces") return; + if (change.operation === "deleted") { + if (!this.knownIds.delete(change.key)) return; + this.publish({ type: "remove", workspaceId: WorkspaceId(change.key) }); + return; + } + if (!this.knownIds.has(change.key)) return; + this.publish({ + type: "upsert", + workspace: changedWorkspaceView(change.key, change.value), + }); + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame); + } +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +class WorkspaceFollower { + private readonly frames = new Deque(); + private waiting: (() => void) | undefined; + private closed = false; + + push(frame: WorkspaceFollowFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return; + this.frames.pushBack(frame); + this.waiting?.(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.waiting?.(); + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.popFront(); + if (frame !== undefined) { + yield frame; + continue; + } + await this.wait(signal); + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener("abort", finish); + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined; + resolve(); + }; + this.waiting = finish; + signal.addEventListener("abort", finish, { once: true }); + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.size > 0) finish(); + }); + } +} diff --git a/apps/web-ui/packages/api/workspace-controller/src/index.ts b/apps/web-ui/packages/api/workspace-controller/src/index.ts new file mode 100644 index 000000000..a5a4f7d1b --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/index.ts @@ -0,0 +1,202 @@ +/** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ + +import { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { WorkspaceCommands } from "./commands.ts"; +import { DirectoryPickerController } from "./directory-picker.ts"; +import { WorkspaceFeed, workspaceView } from "./feed.ts"; +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from "./default-directory.ts"; +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspacePinSessionRequest, + WorkspacePinValue, + WorkspaceRenameRequest, + WorkspaceUnarchiveSessionRequest, + WorkspaceUnpinSessionRequest, + WorkspaceValue, +} from "./types.ts"; + +export type * from "./types.ts"; +export { DirectoryPickerController } from "./directory-picker.ts"; + +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string; + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number; +} + +/** Directory policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { documentsLookupTimeoutMs: number }; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Workspace business API and Remote namespace owner. */ + workspaceController: WorkspaceController; + } +} + +/** Host service backing the generated `ctx.remote.workspace` namespace. */ +export class WorkspaceController extends TypertRemoteService { + static inject = ["typert", "workspaceRegistry"]; + + static Config: z = z.object({ + documentsDirectory: z.string(), + documentsLookupTimeoutMs: z.natural().min(1).default(10_000), + }); + + private readonly config: ResolvedConfig; + private readonly commands: WorkspaceCommands; + private readonly feed: WorkspaceFeed; + + /** + * @param ctx - Host context containing the Workspace registry. + * @param config - first-use directory policy. + */ + constructor(ctx: Context, config: Config = {}) { + super(ctx, "workspaceController", { namespace: "workspace" }); + this.config = WorkspaceController.Config(config); + if (this.config.documentsDirectory !== undefined) + validateDocumentsDirectory(this.config.documentsDirectory); + this.commands = new WorkspaceCommands(ctx); + this.feed = new WorkspaceFeed(ctx); + // This package is the Loader entry for both Remote owners it hosts: the + // directory-picking seam is abstract and never an entry itself. The child + // stays pending until a picking backend is composed, so a host without one + // registers no picking namespace instead of answering an unservable verb. + ctx.plugin(DirectoryPickerController); + } + + /** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + @Remote("create") + create(request: WorkspaceCreateRequest): Promise { + return this.commands.create(request); + } + + /** + * Initialize or reuse the default Workspace during first-use startup. The + * directory name is fixed, so the Host never renames or relocates an + * existing default; its initial title is that same name, which browser + * consumers label in the reader's language. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. + */ + @Remote("initializeDefault") + async initializeDefault(signal: AbortSignal): Promise { + const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs); + return await defaultWorkspaceDirectory( + this.config.documentsDirectory, + AbortSignal.any([signal, timeout]), + ); + }); + return workspace === undefined ? undefined : { workspace: workspaceView(workspace) }; + } + + /** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + @Remote("rename") + rename(request: WorkspaceRenameRequest): Promise { + return this.commands.rename(request); + } + + /** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + @Remote("delete") + delete(request: WorkspaceDeleteRequest): Promise { + return this.commands.delete(request); + } + + /** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + @Remote("insertBefore") + insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + return this.commands.insertBefore(request); + } + + /** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + @Remote("insertSessionBefore") + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + return this.commands.insertSessionBefore(request); + } + + /** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + @Remote("archiveSession") + archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + return this.commands.archiveSession(request); + } + + /** + * Restore one archived Session to Workspace grouping surfaces. + * @param request - Session identity to unarchive. + * @returns the complete resulting archive set. + */ + @Remote("unarchiveSession") + unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise { + return this.commands.unarchiveSession(request); + } + + /** + * Surface one known unarchived Session ahead of unpinned Sessions. + * @param request - Session identity to pin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote("pinSession") + pinSession(request: WorkspacePinSessionRequest): Promise { + return this.commands.pinSession(request); + } + + /** + * Remove one Session's pin without changing its saved Session order. + * @param request - Session identity to unpin. + * @returns the complete resulting pin set, most recently pinned first. + */ + @Remote("unpinSession") + unpinSession(request: WorkspaceUnpinSessionRequest): Promise { + return this.commands.unpinSession(request); + } + + /** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + @Remote({ mode: "stream" }) + follow(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal); + } +} + +export default WorkspaceController; diff --git a/apps/web-ui/packages/api/workspace-controller/src/types.ts b/apps/web-ui/packages/api/workspace-controller/src/types.ts new file mode 100644 index 000000000..b73843045 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/src/types.ts @@ -0,0 +1,176 @@ +/** + * Browser-safe request, result, and state-stream vocabulary for the Workspace + * and directory-picking Remote namespaces this package owns. The picking seam + * declares its own listing types, so they are re-exported here rather than + * restated: a browser consumer reads the very declaration the backend answers. + */ + +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { SessionActivity, WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; + +export type { WorkspaceId } from "@deepseek-ai/dsh-workspace/types"; +export type { + SessionActivity, + SessionActivityItem, + SessionActivityKind, + SessionActivityKindMap, +} from "@deepseek-ai/dsh-workspace/types"; +export type { + DirectoryEntry, + DirectoryListing, +} from "@deepseek-ai/dsh-host-directory-picker/types"; + +/** One durable Workspace projected for browser consumers. */ +export interface WorkspaceView { + readonly workspaceId: WorkspaceId; + /** Canonical host directory path. */ + readonly path: string; + /** User-visible title. */ + readonly title: string; + /** Sessions accounted to this Workspace in manual order. */ + readonly sessionIds: readonly SessionId[]; + /** ISO-8601 creation instant. */ + readonly createdAt: string; + /** ISO-8601 last-mutation instant. */ + readonly updatedAt: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The requested directory cannot back a Workspace. */ + "workspace/invalid-path": { readonly path: string }; + /** Another Workspace already uses the requested name. */ + "workspace/name-conflict": { readonly name: string }; + /** + * The Session still has running work — its own turn, a subagent, a + * background job, or an active schedule — so archiving was refused + * without a write; `activity` names what must stop first. + */ + "workspace/session-active": { + readonly sessionId: SessionId; + readonly activity: readonly SessionActivity[]; + }; + /** The Session or its anchor is not in the Workspace's manual order. */ + "workspace/move-invalid": { + readonly workspaceId: WorkspaceId; + readonly sessionId: SessionId; + readonly beforeSessionId?: SessionId; + }; + /** The verb needs an interaction the composed backend does not serve. */ + "directory-picker/unavailable": { readonly capability: string }; + /** The target is not fully qualified, or the backend cannot list it. */ + "directory-picker/unreadable": { readonly path: string }; + /** A child of that name is already there. */ + "directory-picker/exists": { readonly path: string }; + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + "directory-picker/create-failed": { readonly path: string }; + } +} + +/** Existing directory requested for Workspace adoption. */ +export interface WorkspaceCreateRequest { + readonly path: string; +} + +/** Created or previously registered Workspace. */ +export interface WorkspaceCreateValue { + readonly workspace: WorkspaceView; + readonly created: boolean; +} + +/** Workspace title mutation. */ +export interface WorkspaceRenameRequest { + readonly workspaceId: WorkspaceId; + readonly title: string; +} + +/** Workspace mutation returning the complete changed row. */ +export interface WorkspaceValue { + readonly workspace: WorkspaceView; +} + +/** Workspace registration deletion. */ +export interface WorkspaceDeleteRequest { + readonly workspaceId: WorkspaceId; +} + +/** Receipt after one Workspace registration is deleted. */ +export interface WorkspaceDeleteValue { + readonly deleted: true; +} + +/** DOM-insertBefore-like Workspace order mutation. */ +export interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId; + readonly beforeWorkspaceId?: WorkspaceId; +} + +/** Complete Workspace registry order after a mutation. */ +export interface WorkspaceOrderValue { + readonly workspaceIds: readonly WorkspaceId[]; +} + +/** DOM-insertBefore-like Session membership order mutation. */ +export interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId; + readonly sessionId: SessionId; + readonly beforeSessionId?: SessionId; +} + +/** Session requested for archival from Workspace grouping surfaces. */ +export interface WorkspaceArchiveSessionRequest { + readonly sessionId: SessionId; + /** + * Stop the Session's running work — its turn, subagent descendants, owned + * background jobs, and active schedules — instead of refusing the archive + * as `workspace/session-active`. The stops are requested before the + * archive write and are not awaited; the response arrives once the archive + * set is durable. + */ + readonly stopActivity?: boolean; +} + +/** Session requested for restoration from the archived Session list. */ +export interface WorkspaceUnarchiveSessionRequest { + readonly sessionId: SessionId; +} + +/** Complete archived Session set after a mutation. */ +export interface WorkspaceArchiveValue { + readonly archivedSessionIds: readonly SessionId[]; +} + +/** Session requested for pinning ahead of unpinned Sessions on grouping surfaces. */ +export interface WorkspacePinSessionRequest { + readonly sessionId: SessionId; +} + +/** Session requested for removal from the pin set. */ +export interface WorkspaceUnpinSessionRequest { + readonly sessionId: SessionId; +} + +/** Complete pinned Session set after a mutation, most recently pinned first. */ +export interface WorkspacePinValue { + readonly pinnedSessionIds: readonly SessionId[]; +} + +/** Complete reconnect baseline for Workspace browser state. */ +export interface WorkspaceBaseline { + readonly items: readonly WorkspaceView[]; + readonly archivedSessionIds: readonly SessionId[]; + /** Registry-global pin set, most recently pinned first. */ + readonly pinnedSessionIds: readonly SessionId[]; +} + +/** One ordered Workspace change after a generation's baseline. */ +export type WorkspaceFollowIncrement = + | { readonly type: "upsert"; readonly workspace: WorkspaceView } + | { readonly type: "remove"; readonly workspaceId: WorkspaceId } + | { readonly type: "order"; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: "archived"; readonly archivedSessionIds: readonly SessionId[] } + | { readonly type: "pinned"; readonly pinnedSessionIds: readonly SessionId[] }; + +/** Workspace state stream; every generation starts with exactly one baseline. */ +export type WorkspaceFollowFrame = + { readonly type: "baseline"; readonly value: WorkspaceBaseline } | WorkspaceFollowIncrement; diff --git a/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts b/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts new file mode 100644 index 000000000..dcf69e97c --- /dev/null +++ b/apps/web-ui/packages/api/workspace-controller/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-workspace-controller", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts b/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts new file mode 100644 index 000000000..5877816ac --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/generated/remote.d.ts @@ -0,0 +1,36 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + RemoteStreamHandle, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceByteReadOptions, WorkspaceDirectoryListing, WorkspaceFileBytes, WorkspaceFileRange, WorkspaceFileStat, WorkspaceFileText, WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +type RemoteDecoded = Value extends Uint8Array ? Uint8Array + : Value extends string | number | boolean | null | undefined ? Value + : { [Key in keyof Value]: RemoteDecoded } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$776f726b737061636546696c6573 { + changes: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => RemoteStreamHandle + list: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + read: (workspaceFileScopeId: SessionId, path: string, range: WorkspaceFileRange, signal?: AbortSignal) => Promise> + readBytes: (workspaceFileScopeId: SessionId, path: string, options: WorkspaceByteReadOptions, signal?: AbortSignal) => Promise>> + stat: (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'workspaceFiles/changes': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => RemoteStreamHandle + 'workspaceFiles/list': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + 'workspaceFiles/read': (workspaceFileScopeId: SessionId, path: string, range: WorkspaceFileRange, signal?: AbortSignal) => Promise> + 'workspaceFiles/readBytes': (workspaceFileScopeId: SessionId, path: string, options: WorkspaceByteReadOptions, signal?: AbortSignal) => Promise>> + 'workspaceFiles/stat': (workspaceFileScopeId: SessionId, path: string, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'workspaceFiles': TypertRemoteNamespace$776f726b737061636546696c6573 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/api/workspace-files/generated/remote.js b/apps/web-ui/packages/api/workspace-files/generated/remote.js new file mode 100644 index 000000000..4b92920b5 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/generated/remote.js @@ -0,0 +1,336 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema$value ??= z.union([z.object({ + 'kind': z.literal("ready").readonly(), +}), z.object({ + 'kind': z.literal("change").readonly(), + 'change': z.union([z.object({ + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), +}), z.object({ + 'absolutePath': z.string().readonly(), + 'absent': z.literal(true).readonly(), +})]).readonly(), +})])) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema$value ??= z.object({ + 'path': z.string().readonly(), + 'entries': z.array(z.object({ + 'name': z.string().readonly(), + 'type': z.union([z.literal("file"), z.literal("directory"), z.literal("other")]).readonly(), + 'size': z.number().readonly().optional(), +})).readonly(), + 'truncated': z.boolean().readonly(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema$value ??= z.object({ + 'offset': z.number().readonly().optional(), + 'limit': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema$value ??= z.object({ + 'offset': z.number().readonly(), + 'text': z.string().readonly(), + 'lines': z.number().readonly(), + 'eof': z.boolean().readonly(), + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema$value ??= z.object({ + 'range': z.object({ + 'offset': z.number().readonly().optional(), + 'length': z.number().readonly().optional(), +}).readonly().optional(), + 'baseFile': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema$value ??= z.object({ + 'offset': z.number().readonly(), + 'data': z.instanceof(Uint8Array), + 'eof': z.boolean().readonly(), + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value +const _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema = () => (_deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema$value ??= z.object({ + 'absolutePath': z.string().readonly(), + 'version': z.string().readonly(), + 'bytes': z.number().readonly().optional(), +})) +const $resultSnapshot = (input, path) => { + if (input === null || typeof input !== 'object' || input instanceof Uint8Array) return input + const toJSON = input.toJSON + const value = typeof toJSON === 'function' ? toJSON.call(input, path.at(-1)?.toString() ?? 'value') : input + if (value === null || typeof value !== 'object' || value instanceof Uint8Array) return value + if (Array.isArray(value)) { + const items = [] + for (let index = 0, length = value.length; index < length; index++) items.push(value[index]) + return items + } + const fields = {} + for (const key of Object.keys(value)) { + const item = key === 'toJSON' && value === input ? toJSON : value[key] + if (key === 'toJSON' && typeof item === 'function') continue + Object.defineProperty(fields, key, { value: item, enumerable: true, writable: true, configurable: true }) + } + return fields +} +const $resultContainer = (input, path, ancestors, project, snapshot) => { + if (input === null || typeof input !== 'object') return project(input) + const owner = !ancestors.has(input) + if (!owner && ancestors.get(input) !== path.length) throw new TypeError('Remote result contains a circular object') + if (owner) ancestors.set(input, path.length) + try { return project(snapshot ? $resultSnapshot(input, path) : input) } + finally { if (owner) ancestors.delete(input) } +} +const $encode1 = (value, writeBytes, path, ancestors) => { + return value instanceof Uint8Array ? writeBytes(value, path) : value +} +const $encode0 = (value, writeBytes, path, ancestors) => { + return $resultContainer(value, path, ancestors, value => { + if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Uint8Array) return value + if (Object.hasOwn(value, 'data')) value['data'] = $encode1(value['data'], writeBytes, [...path, 'data'], ancestors) + return value + }, true) +} + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-api-workspace-files', + descriptors: [ + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'changes', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/changes:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileWatchFrame', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_changes_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":340,"column":3}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'list', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/list:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceDirectoryListing', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_list_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":303,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/read:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_1$schema, + }, + }, + { + name: 'range', + wire: 'range', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileRange', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileText', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_read_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":233,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'readBytes', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/readBytes:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_1$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceByteReadOptions', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_parameter_2$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileBytes', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema, + decode: value => _deepseek_ai_dsh_api_workspace_files_workspaceFiles_readBytes_result$schema().parse(value), + encode: (value, writeBytes) => $encode0(value, writeBytes, [], new Map()), + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":257,"column":9}, + }, + { + id: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat', + service: 'workspaceFiles', + namespace: 'workspaceFiles', + method: 'stat', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'workspaceFileScope', + wire: 'workspaceFileScopeId', + source: 'lookup', + lookup: 'workspaceFileScope', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_0$schema, + }, + }, + { + name: 'path', + wire: 'path', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files#workspaceFiles/stat:path', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-api-workspace-files/types#WorkspaceFileStat', + create: _deepseek_ai_dsh_api_workspace_files_workspaceFiles_stat_result$schema, + }, + sourceLocation: {"file":"packages/api/workspace-files/src/index.ts","line":290,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/api/workspace-files/package.json b/apps/web-ui/packages/api/workspace-files/package.json new file mode 100644 index 000000000..36d4315fb --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-files", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-resources" + ], + "platform": "web" + } + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/changes.ts b/apps/web-ui/packages/api/workspace-files/src/changes.ts new file mode 100644 index 000000000..301a40b69 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/changes.ts @@ -0,0 +1,187 @@ +/** + * Target-scoped filesystem watches and `fs/observed` invalidations for `changes`. + * Each generation sends `ready` after watcher initialization, then reads current + * target metadata for matching queued and live invalidations. + */ + +import type { Context } from "@deepseek-ai/cordis"; +import { Deque } from "@deepseek-ai/dsh-deque"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { FsObservation, FsTarget } from "@deepseek-ai/dsh-fs"; +import type { WorkspaceFileWatchFrame } from "./types.ts"; + +/** One target invalidation, optionally carrying its instrumented observation. */ +type Observed = readonly [target: FsTarget, observation?: FsObservation]; + +/** Owns target watches, instrumented observations, and every open `changes` generation. */ +export class WorkspaceChangeFeed { + private readonly followers = new Set(); + + /** @param ctx - Host context carrying the filesystem the observations come from. */ + constructor(private readonly ctx: Context) { + ctx.on("fs/observed", (target, observation) => { + for (const follower of this.followers) follower.push([target, observation]); + }); + ctx.effect( + () => async () => { + const followers = [...this.followers]; + await Promise.all(followers.map((follower) => follower.close())); + this.followers.clear(); + }, + "workspace-files.changes", + ); + } + + /** + * Open one target watch; directory targets remain inside `workspaceRoot`. + * @param workspaceRoot - the session's workspace root path. + * @param path - target path, resolved relative to the workspace root; Host metadata determines its type. + * @param signal - generation cancellation. + * @returns `ready` after watching starts, then current metadata for target invalidations. + */ + async *follow( + workspaceRoot: string, + path: string, + signal: AbortSignal, + ): AsyncIterable { + signal.throwIfAborted(); + // Instrumented observations remain queued while the target resolves. + const follower = new ChangeFollower(() => { + signal.removeEventListener("abort", cancel); + this.followers.delete(follower); + }); + signal = AbortSignal.any([signal, follower.controller.signal]); + const aborted = (): boolean => signal.aborted; + const cancel = (): void => { + void follower.close().catch((error: unknown) => { + this.ctx.logger.error(error); + }); + }; + signal.addEventListener("abort", cancel, { once: true }); + this.followers.add(follower); + let unwatch: (() => Promise) | undefined; + try { + // Setup shares cancellation, so a late resolution cannot acquire a watcher. + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (root === undefined || aborted() || follower.isClosed) return; + const target = await this.ctx.fs + .resolve(path, { cwd: workspaceRoot, signal }) + .catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (target === undefined || aborted()) return; + const stat = async () => { + const info = await this.ctx.fs.stat(target, signal).catch((error: unknown) => { + if (aborted()) return undefined; + throw error; + }); + if (info?.type === "directory" && !this.ctx.fs.contains(root, target)) { + throw new RemoteError( + "workspace-file/outside-workspace", + "Directory is outside the workspace", + { path }, + ); + } + return info; + }; + await stat(); + if (aborted()) return; + try { + unwatch = await this.ctx.fs.watch( + target, + (error) => { + if (error !== undefined) follower.fail(error); + else if (!follower.isClosed) follower.push([target]); + }, + signal, + ); + if (follower.error !== undefined) throw follower.error; + } catch (error) { + if (aborted() && follower.error === undefined) return; + const failure = follower.error ?? error; + throw new RemoteError( + "workspace-file/watch-unsupported", + failure instanceof Error ? failure.message : String(failure), + { path }, + ); + } + follower.initialized.resolve(unwatch); + if (aborted()) return; + yield { kind: "ready" }; + for await (const [observed] of follower.read()) { + if (observed.targetKey !== target.targetKey) continue; + const info = await stat(); + if (aborted()) return; + const absolutePath = this.ctx.fs.processPath(target); + yield { + kind: "change", + change: + info !== undefined + ? { absolutePath, version: info.version } + : { absolutePath, absent: true }, + }; + } + } finally { + follower.initialized.resolve(unwatch); + await follower.close(); + } + } +} + +/** One generation's queue: observations wait here until its consumer pulls them. */ +class ChangeFollower { + readonly controller = new AbortController(); + readonly initialized = Promise.withResolvers<(() => Promise) | undefined>(); + error: Error | undefined; + private readonly queue = new Deque(); + private wake: (() => void) | undefined; + private closed = false; + private closing: Promise | undefined; + + constructor(private readonly release: () => void) {} + + /** Whether this generation has stopped accepting invalidations. */ + get isClosed(): boolean { + return this.closed; + } + + push(observed: Observed): void { + if (this.closed) return; + this.queue.pushBack(observed); + this.wake?.(); + } + + close(): Promise { + if (this.closing !== undefined) return this.closing; + this.closed = true; + this.closing = this.initialized.promise.then((unwatch) => unwatch?.()).finally(this.release); + this.controller.abort(); + this.wake?.(); + return this.closing; + } + + fail(error: Error): void { + this.error = error; + this.controller.abort(); + } + + /** Drain until closed or aborted; anything still queued then is dropped with the generation. */ + async *read(): AsyncIterable { + while (!this.closed) { + const observed = this.queue.popFront(); + if (observed !== undefined) { + yield observed; + continue; + } + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + if (this.error !== undefined) throw this.error; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts b/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts new file mode 100644 index 000000000..db767e559 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/change-feed.ts @@ -0,0 +1,309 @@ +/** + * One Host `changes` subscription per Session and requested path, shared by + * that target's open resources. + * + * The Host reports invalidations from filesystem watches and instrumented writes. + * The feed opens the target stream when the first follower + * arrives, hands each frame to the followers of its path, and disposes the + * stream when the last follower leaves. A follower buffers target changes + * until `stat` supplies its Host absolute path, then filters queued and live + * frames by that path, with `\\` normalized to `/`. + */ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from "../types.ts"; +import type { SupervisedStream, WorkspaceFilesRemote } from "./remote.ts"; +import type { WorkspaceFileEdit, WorkspaceFileNotice } from "./types.ts"; + +/** + * The follower key of one absolute path. + * @param path - an absolute path from a Host stat or change frame. + * @returns the path with `\\` normalized to `/`. + */ +function keyOf(path: string): string { + return path.replace(/\\/g, "/"); +} + +/** Notices of one follower, delivered in order and pulled by its consumer. */ +class Follower implements AsyncIterable { + private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = + []; + private readonly started = Promise.withResolvers(); + private wake: (() => void) | undefined; + private ended = false; + private hostKey: string | undefined; + + /** + * Resolves true after the Host acknowledges its subscription, or false if + * this follower ends before acknowledgement. + */ + readonly ready = this.started.promise; + + /** + * @param leave - unregisters this follower and its abort listener. + */ + constructor(private readonly leave: () => void) {} + + /** + * Select the Host path for queued and future changes. + * @param absolutePath - the successful stat's absolute path. + */ + bind(absolutePath: string): void { + this.hostKey = keyOf(absolutePath); + } + + /** The Host acknowledged an active subscription and resolved workspace root. */ + start(): void { + this.started.resolve(true); + } + + /** + * Queue one notice. + * @param notice - what the consumer receives next. + * @param key - normalized Host path for the change. + */ + push(notice: WorkspaceFileNotice, key: string): void { + this.pending.push({ key, notice }); + this.wake?.(); + } + + /** Deliver what is queued, then finish. */ + end(): void { + this.ended = true; + this.started.resolve(false); + this.wake?.(); + } + + /** Unregister even when the consumer has not started pulling notices. */ + dispose(): void { + this.leave(); + } + + /** @inheritdoc */ + async *[Symbol.asyncIterator](): AsyncIterator { + try { + while (true) { + const next = this.pending.shift(); + if (next !== undefined) { + if ( + next.notice.kind === "refresh" || + this.hostKey === undefined || + next.key === this.hostKey + ) + yield next.notice; + continue; + } + if (this.ended) return; + await new Promise((resolve) => { + this.wake = resolve; + }); + this.wake = undefined; + } + } finally { + this.dispose(); + } + } +} + +/** The stream and followers of one Session and requested path. */ +class SessionFeed { + private readonly followers = new Set(); + private readonly stream: SupervisedStream; + private closed = false; + private started = false; + private acknowledged = false; + + /** + * @param remote - the Remote face carrying `workspaceFiles.changes`. + * @param sessionId - the Session whose filesystem this feed observes. + * @param path - target path submitted to the Host. + * @param after - the previous feed of this target still closing, if any; the stream opens once it has settled. + * @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it. + */ + constructor( + remote: WorkspaceFilesRemote, + sessionId: SessionId, + path: string, + after: Promise | undefined, + private readonly onClose: (closed: Promise) => void, + ) { + this.stream = remote.$stream({ + name: `workspace file changes of ${sessionId}`, + // A predecessor still closing finishes first, so one target never has + // two Host streams open at once. + open: (signal) => { + this.started = false; + return openAfter(after, () => remote.workspaceFiles.changes(sessionId, path, signal)); + }, + // A normal end means the Host closed the target's feed. + ended: () => new Error(`workspace file changes of ${sessionId} ended`), + }); + void this.pump(); + } + + /** + * Register one resource address before its Host path is known. + * @param follower - receives changes and binds its path after stat. + */ + add(follower: Follower): void { + this.followers.add(follower); + if (this.started) follower.start(); + } + + /** + * Unregister one follower; the last one leaving disposes the stream. + * @param follower - the follower to drop. + */ + remove(follower: Follower): void { + this.followers.delete(follower); + if (this.followers.size === 0) this.close(); + } + + private async pump(): Promise { + try { + for await (const item of this.stream) { + const frame = item.value; + switch (frame.kind) { + case "ready": + item.accept(); + this.started = true; + for (const follower of this.followers) { + follower.start(); + if (this.acknowledged) follower.push({ kind: "refresh" }, ""); + } + this.acknowledged = true; + break; + case "change": { + const key = keyOf(frame.change.absolutePath); + const notice = editOf(frame.change); + for (const follower of this.followers) follower.push(notice, key); + break; + } + default: + assertNever(frame); + } + } + } catch { + // A terminal stream failure or the Host's end: followers end quietly + // below, and the metadata they hold stays the last known. + } finally { + this.close(); + } + } + + private close(): void { + if (this.closed) return; + this.closed = true; + const closed = this.stream.dispose(); + for (const follower of this.followers) follower.end(); + this.followers.clear(); + this.onClose(closed); + } +} + +/** + * Open a Host stream once a predecessor has finished closing. + * @param after - the predecessor's dispose, or nothing to wait for. + * @param open - opens the stream. + * @returns the stream's items. + */ +async function* openAfter( + after: Promise | undefined, + open: () => AsyncIterable, +): AsyncIterable { + await after; + yield* open(); +} + +/** + * The write one Host frame reports. + * @param frame - the Host frame. + * @returns the edit notice followers receive. + */ +function editOf(frame: WorkspaceFileChange): WorkspaceFileEdit { + return "absent" in frame ? { kind: "absent" } : { kind: "changed", version: frame.version }; +} + +function assertNever(frame: never): never { + throw new Error(`Unexpected workspace file watch frame: ${JSON.stringify(frame)}`); +} + +/** + * Per-target sharing of the Host's workspace file change streams. + * + * Owned by the provider; one instance serves every session of the Client. + */ +export class ChangeFeed { + /** Live feeds only: a feed removes itself when its stream closes. */ + private readonly sessions = new Map(); + /** Streams still closing, by Session and path; a successor waits for its predecessor. */ + private readonly closing = new Map>(); + + /** + * @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`. + */ + constructor(private readonly remote: WorkspaceFilesRemote) {} + + /** + * Follow one resource in one session before its Host path is known. + * + * The follower is registered on call, not on first pull. Changes delivered + * to this Client are queued while stat is pending. The first follower starts + * target's local `changes` call. The iterable ends + * when `signal` aborts or when the target stream is gone; ending it early + * (`break`, `return`) unregisters the follower as well, and the last follower + * of a target disposes its stream. Await a true `ready` result before stat + * so the Host subscription is active, then bind each stat's absolute path. Until binding, + * any target invalidation can trigger a retry; after binding, only matching queued + * and live changes pass. + * @param sessionId - the Session providing the file's read authority. + * @param path - requested file path; followers share a stream only for the same Session and path. + * @param signal - ends the follow. + * @returns a single-consumer subscription with Host-path binding and explicit disposal. + */ + follow(sessionId: SessionId, path: string, signal: AbortSignal): Follower { + const feed = signal.aborted ? undefined : this.feedOf(sessionId, path); + const leave = (): void => { + signal.removeEventListener("abort", leave); + follower.end(); + feed?.remove(follower); + }; + const follower = new Follower(leave); + if (feed === undefined) { + follower.end(); + } else { + feed.add(follower); + signal.addEventListener("abort", leave, { once: true }); + } + return follower; + } + + /** + * Wait for every stream that is still closing, so an owner tearing down + * leaves no Host stream behind. + * @returns resolves once no stream of this feed is closing. + */ + async settle(): Promise { + await Promise.all(this.closing.values()); + } + + private feedOf(sessionId: SessionId, path: string): SessionFeed { + const key = JSON.stringify([sessionId, path]); + const existing = this.sessions.get(key); + if (existing !== undefined) return existing; + const feed = new SessionFeed(this.remote, sessionId, path, this.closing.get(key), (closed) => { + this.sessions.delete(key); + // A dispose that rejects is still a settled close: nothing remains to wait for. + const tracked: Promise = closed + .then( + () => undefined, + () => undefined, + ) + .then(() => { + if (this.closing.get(key) === tracked) this.closing.delete(key); + }); + this.closing.set(key, tracked); + }); + this.sessions.set(key, feed); + return feed; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/index.ts b/apps/web-ui/packages/api/workspace-files/src/client/index.ts new file mode 100644 index 000000000..7020e7339 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/index.ts @@ -0,0 +1,35 @@ +/** + * Browser half: the `file` resource provider over `remote.workspaceFiles`. + * + * `types.ts` is what the protocol publishes, `change-feed.ts` shares one Host + * `changes` stream per session, `provider.ts` turns it and `stat` into a value + * stream, and this module only wires them into `ctx.resources`. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type {} from "@deepseek-ai/dsh-api-gateway/client"; +import type {} from "@deepseek-ai/dsh-client-resources/client"; +import { ChangeFeed } from "./change-feed.ts"; +import { createFileResourceProvider } from "./provider.ts"; + +export type { WorkspaceFileParams } from "./types.ts"; + +/** Required browser services: the resource model, the Remote carrier and its namespace. */ +export const inject = ["resources", "remote", "remote.workspaceFiles"]; + +/** + * Client plugin body: register the `file` provider for this plugin's lifetime. + * @param ctx - client root context carrying `resources` and the Remote face. + */ +export function apply(ctx: ClientContext): void { + const changes = new ChangeFeed(ctx.remote); + const provider = createFileResourceProvider(ctx.remote, changes); + ctx.effect(() => { + const release = ctx.resources.register(provider); + // Teardown waits for every session stream still closing, so the plugin + // leaves no Host stream behind. + return async () => { + release(); + await changes.settle(); + }; + }, "workspace-files: file resource provider"); +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/provider.ts b/apps/web-ui/packages/api/workspace-files/src/client/provider.ts new file mode 100644 index 000000000..21db249ce --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/provider.ts @@ -0,0 +1,155 @@ +/** + * The `file` protocol's provider: a workspace file's metadata as a stream of + * `RemoteResult` frames. + * + * An address names the file in one of two scopes. A `session` address, + * `dsh-resource://file/session//`, carries an absolute path or one + * relative to that Session's workspace root: the Host receives the path as-is and + * resolves it against the root it holds. Only the Host's `stat.absolutePath` + * selects the change-feed key; no Client Session summary is needed. + * An `absolute` address, `dsh-resource://file/absolute/`, carries no + * Session and cannot authorize a Host call. An address neither scope + * resolves yields one failure frame — `workspace-file/unsupported-address` for + * a string outside the grammar, `workspace-file/unknown-workspace` when the + * address carries no Session — and ends. + * + * The first frame is the file's `stat`; subsequent invalidations run `stat` + * again unless their version or absence is already known. Reconnection also + * restats the file. Failures travel as `ok: false` frames, never as thrown errors: the + * Remote face does not reject, and anything thrown inside the stream is a + * programming error the resource model lets surface. A failed stat does not end + * the stream: the next write stats again. One {@link ChangeFeed} + * serves every open file of the Client. + */ +import type { ResourceProvider } from "@deepseek-ai/dsh-client-resources/client"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import { parseFileAddress } from "@deepseek-ai/dsh-util-workspace-path"; +import type { WorkspaceFileStat } from "../types.ts"; +import type { ChangeFeed } from "./change-feed.ts"; +import type { WorkspaceFilesRemote } from "./remote.ts"; + +/** The Session and unmodified path submitted to the Host. */ +interface HostFile { + readonly sessionId: SessionId; + /** The path the Host receives: absolute or workspace-relative. */ + readonly path: string; +} + +/** + * Build the `file` provider over one Remote face and one change feed. + * @param remote - the Remote face carrying `workspaceFiles.stat`. + * @param changes - target-scoped change streams shared by file resources. + * @returns the provider to register into `ctx.resources`. + */ +export function createFileResourceProvider( + remote: WorkspaceFilesRemote, + changes: ChangeFeed, +): ResourceProvider<"file"> { + return { + protocol: "file", + async *open(address, { signal }): AsyncIterable> { + const resolved = resolve(address); + if (!resolved.ok) { + yield resolved; + return; + } + const { sessionId, path } = resolved.value; + // Queue changes delivered to this Client while stat is pending. + const notices = changes.follow(sessionId, path, signal); + const stat = (): Promise> => + remote.workspaceFiles.stat(sessionId, path, signal); + // Read through a call: a plain `signal.aborted` is narrowed to `false` by + // the first check and would read as always-false after the later awaits. + const aborted = (): boolean => signal.aborted; + // Undefined while the last stat failed: the follow is on the address, not + // on the file, so a write can still bring the file live. + let current: WorkspaceFileStat | undefined; + try { + if (!(await notices.ready)) { + if (aborted()) return; + const result = await stat(); + if (!aborted()) yield result; + return; + } + if (aborted()) return; + const first = await stat(); + if (aborted()) return; + if (first.ok) { + notices.bind(first.value.absolutePath); + current = first.value; + yield { ok: true, value: current }; + } else { + yield first; + } + for await (const notice of notices) { + if (aborted()) return; + if (current === undefined) { + // Still gone: nothing new to report. + if (notice.kind === "absent") continue; + } else if (notice.kind === "changed") { + // Frames report observations: holding this version already means the + // consumer learns nothing new. + if (notice.version === current.version) continue; + } + // A Host notice may mean stale content. + const again = await stat(); + if (aborted()) return; + if (!again.ok) { + current = undefined; + yield again; + continue; + } + notices.bind(again.value.absolutePath); + current = again.value; + yield { ok: true, value: current }; + } + } finally { + notices.dispose(); + } + }, + }; +} + +/** + * Resolve one address to the Host call it stands for, or to the failure frame it earns. + * @param address - the full address, scheme included. + * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure. + */ +function resolve(address: string): RemoteResult { + const parsed = parseFileAddress(address); + if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) }; + if (parsed.scope === "session") { + // The address is a string boundary: its id segment is the Session id it names. + const sessionId = parsed.sessionId as SessionId; + return { ok: true, value: { sessionId, path: parsed.path } }; + } + return { ok: false, error: unknownWorkspace(address) }; +} + +/** + * The failure frame's error for an address this provider does not serve. + * @param address - the offending address. + * @returns the typed error. + */ +function unsupportedAddress(address: string): RemoteError<"workspace-file/unsupported-address"> { + return new RemoteError( + "workspace-file/unsupported-address", + `${address} is not a dsh-resource://file/session// or dsh-resource://file/absolute/ address`, + { address }, + ); +} + +/** + * The failure frame's error for an absolute address with no Session. + * @param address - the offending address. + * @returns the typed error. + */ +function unknownWorkspace(address: string): RemoteError<"workspace-file/unknown-workspace"> { + return new RemoteError( + "workspace-file/unknown-workspace", + `${address} requires a dsh-resource://file/session// address`, + { address }, + ); +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/remote.ts b/apps/web-ui/packages/api/workspace-files/src/client/remote.ts new file mode 100644 index 000000000..7f32f57e1 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/remote.ts @@ -0,0 +1,50 @@ +/** + * The slice of the Client Remote this package calls: the generated + * `workspaceFiles` methods by name, and the stream supervisor structurally, so + * the feed and the provider are testable against a scripted face. + */ +import type { ClientRemote } from "@deepseek-ai/dsh-api-gateway/client"; +// Merges the generated `workspaceFiles` namespace into the Remote face. +import type {} from "@deepseek-ai/dsh-api-workspace-files/remote"; + +/** One item of a supervised stream; `accept` marks the delivering generation as healthy. */ +export interface SupervisedStreamItem { + /** The decoded frame. */ + readonly value: Item; + /** Reset the reconnect backoff: this generation is delivering. */ + accept(): void; +} + +/** A reconnecting single-consumer stream the Remote supervises. */ +export interface SupervisedStream extends AsyncIterable> { + /** + * Stop the stream for good. + * @returns once the active generation and the consumer iterator are closed. + */ + dispose(): Promise; +} + +/** What one supervised stream needs from its owner. */ +export interface SupervisedStreamOptions { + /** Diagnostic owner name. */ + readonly name: string; + /** Open one physical generation; `signal` aborts it. */ + readonly open: (signal: AbortSignal) => AsyncIterable; + /** The error a generation's normal end amounts to; a carrier error asks for a reopen, anything else is terminal. */ + readonly ended: (accepted: boolean) => Error; +} + +/** The `workspaceFiles` namespace methods this package calls, as the generated Remote declares them. */ +export type WorkspaceFilesNamespace = Pick; + +/** The Client Remote as this package sees it. */ +export interface WorkspaceFilesRemote { + /** + * Create one reconnecting stream. + * @param options - opener and end classification. + * @returns the supervised stream, unstarted until iterated. + */ + $stream(options: SupervisedStreamOptions): SupervisedStream; + /** The `workspaceFiles` namespace. */ + readonly workspaceFiles: WorkspaceFilesNamespace; +} diff --git a/apps/web-ui/packages/api/workspace-files/src/client/types.ts b/apps/web-ui/packages/api/workspace-files/src/client/types.ts new file mode 100644 index 000000000..b1a8e3a04 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/client/types.ts @@ -0,0 +1,49 @@ +/** + * The `file` protocol's resource metadata, navigation params, Client errors, + * and internal change-feed notices. + */ +// Bring the base `ResourceProtocolMap` declaration into this program so the +// augmentation below merges into it instead of declaring a second interface. +import type {} from "@deepseek-ai/dsh-client-resources/client"; +import type { WorkspaceFileStat } from "../types.ts"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface ResourceProtocolMap { + /** + * One workspace file's metadata, addressed as + * `dsh-resource://file/session//` (absolute or workspace-relative). + */ + file: WorkspaceFileStat; + } +} + +/** What a `file` tab is asked to reveal on open or navigation; JSON-shaped. */ +export interface WorkspaceFileParams { + /** 1-based line to scroll into view; absent leaves the position alone. */ + readonly line?: number; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** + * The address is not a `dsh-resource://file/` address in a scope the + * provider recognizes: `session//` or + * `absolute/`. Raised by the Client provider; the Host never + * emits it. + */ + "workspace-file/unsupported-address": { readonly address: string }; + /** + * An `absolute` address has no Session to authorize its Host call. + * Raised by the Client provider; the Host never emits it. Session addresses + * are resolved by the Host without a Client Session summary. + */ + "workspace-file/unknown-workspace": { readonly address: string }; + } +} + +/** One Host-reported target change in the Session's filesystem. */ +export type WorkspaceFileEdit = + { readonly kind: "changed"; readonly version: string } | { readonly kind: "absent" }; + +/** A target change or a request to restat after reconnection. */ +export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: "refresh" }; diff --git a/apps/web-ui/packages/api/workspace-files/src/index.ts b/apps/web-ui/packages/api/workspace-files/src/index.ts new file mode 100644 index 000000000..4bad89320 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/index.ts @@ -0,0 +1,586 @@ +/** + * Workspace file service: read-only file previews, workspace directory + * listings, and the filesystem-observation change feed, exposed as + * `workspaceFiles`. + * + * File reads follow the composed filesystem's read access, including paths + * outside the workspace. The selected Session header supplies the base for + * relative paths, with the sandbox policy root as its no-cwd fallback, not a + * read-containment restriction. Directory listings and change observations + * remain workspace-scoped. File-kind checks and configured read caps apply to + * every preview; this service exposes no mutations. + * + * A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it + * goes, so the file is read only up to the first character past the page and + * never held whole in memory; the NUL scan runs on the page itself. + * + * This is NOT modelled on `session.openWorkspacePath`. That endpoint hands a + * path to the local opener and leaves the effect on the machine; this one sends + * file content across the wire, which is a different level of exposure. + */ + +import { posix, win32 } from "node:path"; +import type { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-fs"; +import type { FsDirEntry, FsInfo, FsPathInfo, FsTarget } from "@deepseek-ai/dsh-fs"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import type {} from "@deepseek-ai/dsh-session"; +import type {} from "@deepseek-ai/dsh-session-persistence"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { + Remote, + RemoteError, + TypertRemoteService, + type TypertLookup, +} from "@deepseek-ai/dsh-typert-protocol"; +import { WorkspaceChangeFeed } from "./changes.ts"; +import type { + WorkspaceByteRange, + WorkspaceByteReadOptions, + WorkspaceDirectoryEntry, + WorkspaceDirectoryListing, + WorkspaceFileBytes, + WorkspaceFileRange, + WorkspaceFileStat, + WorkspaceFileText, + WorkspaceFileWatchFrame, +} from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host owner of the `workspaceFiles` Remote namespace. */ + workspaceFiles: WorkspaceFiles; + } +} + +/** Header-derived file resolution context for one Session identity. */ +export interface WorkspaceFileScope { + /** Session identity received on the wire. */ + readonly sessionId: SessionId; + /** Session workspace root, or the deployment fallback when its header has no cwd. */ + readonly workspaceRoot: string; +} + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface TypertLookupMap { + /** Resolve a Session id to its workspace root without loading its event body or activating an Agent. */ + workspaceFileScope: TypertLookup; + } +} + +/** Deployment caps on one page or one listing. */ +export interface Config { + /** + * Inclusive byte cap on one page's text and on one byte window. + * + * A page above this fails; it is not shortened, because a silently cut page + * reads as the whole page. A byte window asking for more is refused the same + * way. The file itself has no size cap: a caller pages through it. + */ + readonly maxBytes: number; + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number; + /** Default and largest page size in lines; a request asking for more is refused. */ + readonly maxLines: number; + /** Cap on returned directory entries; the rest is dropped and reported cut. */ + readonly maxEntries: number; +} + +/** One page cut from a decoded text stream. */ +interface Page { + readonly text: string; + /** Lines in `text`; `0` for a page past the last line. */ + readonly lines: number; + readonly eof: boolean; +} + +/** The byte text never carries: its presence marks a page as binary. */ +const NUL = String.fromCharCode(0); + +/** Refuse anything the wire schema admits as a number but a window cannot use: only safe integers index a file. */ +function integerAtLeast(value: number, min: number, name: string): number { + if (!Number.isSafeInteger(value) || value < min) { + throw new RemoteError( + "gateway/bad-request", + `${name} must be a safe integer of at least ${min}`, + {}, + ); + } + return value; +} + +/** + * Cut lines `offset` through `offset + limit - 1` from decoded chunks, stopping + * at the first character past the page so the rest of the file is never read. + * Lines before the page are counted, not kept, and the page is refused the + * moment its bytes exceed `maxBytes`, so one giant line cannot grow memory past + * the cap either. + */ +async function cutPage( + chunks: AsyncIterable, + offset: number, + limit: number, + maxBytes: number, + path: string, +): Promise { + const last = offset + limit - 1; + const lines: string[] = []; + let current = ""; + let bytes = 0; + let lineNumber = 1; + const admit = (size: number): void => { + bytes += size; + if (bytes > maxBytes) { + throw new RemoteError( + "workspace-file/too-large", + `lines ${offset}-${last} of "${path}" exceed the ${maxBytes} byte cap`, + { path, limit: maxBytes }, + ); + } + }; + const complete = (): void => { + if (lines.length > 0) admit(1); + lines.push(current); + current = ""; + }; + for await (const chunk of chunks) { + let position = 0; + while (position < chunk.length) { + if (lineNumber > last) return { text: lines.join("\n"), lines: lines.length, eof: false }; + const newline = chunk.indexOf("\n", position); + const segment = newline === -1 ? chunk.slice(position) : chunk.slice(position, newline); + if (lineNumber >= offset) { + admit(Buffer.byteLength(segment, "utf8")); + current += segment; + } + if (newline === -1) break; + if (lineNumber >= offset) complete(); + lineNumber += 1; + position = newline + 1; + } + } + // Only an in-page line can be pending here: earlier lines were never kept, + // and a character past the page returned above. + if (current.length > 0) complete(); + return { text: lines.join("\n"), lines: lines.length, eof: true }; +} + +/** + * Workspace path of `target` relative to `root`, derived from the two canonical + * `file:` URIs so the answer is `/`-joined on every platform. Empty for the root. + */ +function workspacePathOf(rootUrl: string, targetUrl: string): string { + const root = new URL(rootUrl).pathname.replace(/\/+$/, ""); + const target = new URL(targetUrl).pathname; + if (target === root) return ""; + return target + .slice(root.length + 1) + .split("/") + .map(decodeURIComponent) + .join("/"); +} + +/** Strip the resolved child target: the wire carries names and metadata only. */ +function directoryEntry(child: FsDirEntry): WorkspaceDirectoryEntry { + return { + name: child.name, + type: child.type, + ...(child.size === undefined ? {} : { size: child.size }), + }; +} + +/** Host Remote file reads and workspace directory observations over the composed filesystem. */ +export class WorkspaceFiles extends TypertRemoteService { + static inject = ["fs", "sandboxPolicy", "sessions", "typert"]; + + static Config: z = z.object({ + maxBytes: z + .number() + .step(1) + .min(1) + .default(2 * 1024 * 1024), + maxFileBytes: z + .number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER - 1) + .default(32 * 1024 * 1024), + maxLines: z.number().step(1).min(1).default(5000), + maxEntries: z.number().step(1).min(1).default(2000), + }); + + private readonly feed: WorkspaceChangeFeed; + + /** + * @param ctx - Host context carrying the filesystem and the sandbox policy. + * @param config - deployment caps on one page or one listing. + */ + constructor( + ctx: Context, + private readonly config: Config, + ) { + super(ctx, "workspaceFiles"); + this.feed = new WorkspaceChangeFeed(ctx); + ctx.inject(["sessions", "typert"], (scope) => { + scope.typert.lookups.register("workspaceFileScope", { + parameter: "workspaceFileScope", + wire: "workspaceFileScopeId", + hostTypeSymbol: "@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope", + wireTypeSymbol: "@deepseek-ai/dsh-session/types#SessionId", + resolve: async (sessionId) => { + const live = scope.sessions.get(sessionId)?.header; + const stored = + live === undefined ? await scope.get("sessionPersistence")?.stat(sessionId) : undefined; + const header = live ?? stored?.header; + if (header === undefined) return undefined; + return { + sessionId, + workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot, + }; + }, + }); + }); + } + + /** + * Read one page of lines from a UTF-8 file readable by the filesystem backend. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. + * @param range - the line window; omitted fields take the page defaults. + * @param signal - caller cancellation. + * @returns the page, the file's version at the stat before it, and whether it reaches the last line. + */ + @Remote + async read( + workspaceFileScope: WorkspaceFileScope, + path: string, + range: WorkspaceFileRange, + signal: AbortSignal, + ): Promise { + const { offset, limit } = this.resolvePage(range); + const { target, info } = await this.locateFile(workspaceFileScope, path, signal); + const page = await this.cutPage(target, offset, limit, signal, path); + if (page.text.includes(NUL)) { + throw new RemoteError("workspace-file/not-text", `"${path}" contains NUL bytes`, { path }); + } + return { + ...this.statOf(target, info), + offset, + text: page.text, + lines: page.lines, + eof: page.eof, + }; + } + + /** + * Read a complete regular file or one byte range without text decoding. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. + * @param signal - caller cancellation. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. + */ + @Remote + async readBytes( + workspaceFileScope: WorkspaceFileScope, + path: string, + options: WorkspaceByteReadOptions, + signal: AbortSignal, + ): Promise { + const window = + options.range === undefined ? undefined : this.resolveWindow(options.range, path); + const resolved = + options.baseFile === undefined + ? path + : await this.relativePath(workspaceFileScope, options.baseFile, path, signal); + const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal); + if (window !== undefined) { + const { offset, length } = window; + const data = await this.ctx.fs.readByteRange(target, window, signal); + const eof = + info.size === undefined ? data.length < length : offset + data.length >= info.size; + return { ...this.statOf(target, info), offset, data, eof }; + } + const limit = this.config.maxFileBytes; + const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { + if ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "FS_TOO_LARGE" + ) { + throw new RemoteError( + "workspace-file/too-large", + `"${path}" exceeds the ${limit} byte full-file cap`, + { path, limit }, + { cause }, + ); + } + throw cause; + }); + return { ...this.statOf(target, info), offset: 0, data, eof: true }; + } + + /** + * Report one regular file's identity, version, and size without its content. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - absolute path or path relative to the workspace root; files outside it are allowed. + * @param signal - caller cancellation. + * @returns the file's absolute path, current version, and byte size. + */ + @Remote + async stat( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise { + const { target, info } = await this.locateFile(workspaceFileScope, path, signal); + return this.statOf(target, info); + } + + /** + * List the direct children of one directory inside the Session's workspace. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - workspace path, absolute or relative to the workspace root. + * @param signal - caller cancellation. + * @returns the directory's children in the backend's stable name order, bounded by the entry cap. + */ + @Remote + async list( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise { + const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal); + // A final link — a Windows junction or a symlink — is listed through the + // directory it resolves to, matching the child type `listDir` reports for + // that entry; `read` keeps its own no-follow gate on the final component. + if (entry.type !== "directory" && entry.type !== "symlink") { + throw new RemoteError("workspace-file/not-directory", `"${path}" is a ${entry.type}`, { + path, + kind: entry.type, + }); + } + const target = await this.confine(root, workspaceRoot, path, signal); + if (entry.type === "symlink") { + const info = await this.ctx.fs.stat(target, signal); + if (info?.type !== "directory") { + throw new RemoteError( + "workspace-file/not-directory", + `"${path}" does not resolve to a directory`, + { path, kind: "symlink" }, + ); + } + } + const children = await this.ctx.fs.listDir(target, signal); + return { + path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)), + entries: children.slice(0, this.config.maxEntries).map(directoryEntry), + truncated: children.length > this.config.maxEntries, + }; + } + + /** + * Watch one file or a directory's direct entries in the Session's filesystem. + * Files use the backend's read authority; directories remain workspace-scoped. + * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. + * @param path - target path; the Host determines its type and confines directories to the workspace. + * @param signal - generation cancellation. + * @returns `ready` once the target watch is active, then current metadata for queued and live invalidations. + * @throws RemoteError when watching is unavailable or a directory is outside the workspace. + */ + @Remote({ mode: "stream" }) + changes( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): AsyncIterable { + return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal); + } + + private async relativePath( + scope: WorkspaceFileScope, + baseFile: string, + path: string, + signal: AbortSignal, + ): Promise { + const relative = path.replace(/\\/g, "/"); + if ( + relative.length === 0 || + relative.startsWith("/") || + /^[a-z][a-z\d+.-]*:/iu.test(relative) || + relative.includes(NUL) + ) { + throw new RemoteError( + "gateway/bad-request", + "path must be relative when baseFile is provided", + {}, + ); + } + const { target } = await this.locateFile(scope, baseFile, signal); + const absolute = this.ctx.fs.processPath(target); + const paths = absolute.startsWith("/") ? posix : win32; + return paths.resolve(paths.dirname(absolute), relative); + } + + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ + private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { + const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, "offset"); + const limit = + range.limit === undefined ? this.config.maxLines : integerAtLeast(range.limit, 1, "limit"); + if (limit > this.config.maxLines) { + throw new RemoteError( + "gateway/bad-request", + `limit must be at most ${this.config.maxLines}`, + {}, + ); + } + return { offset, limit }; + } + + /** Apply the byte-window defaults and cap; a window above the cap is refused, not shortened. */ + private resolveWindow( + range: WorkspaceByteRange, + path: string, + ): { offset: number; length: number } { + const offset = range.offset === undefined ? 0 : integerAtLeast(range.offset, 0, "offset"); + const length = + range.length === undefined ? this.config.maxBytes : integerAtLeast(range.length, 1, "length"); + if (offset + length > Number.MAX_SAFE_INTEGER) { + throw new RemoteError( + "gateway/bad-request", + "offset plus length must stay a safe integer", + {}, + ); + } + if (length > this.config.maxBytes) { + throw new RemoteError( + "workspace-file/too-large", + `${length} bytes of "${path}" exceed the ${this.config.maxBytes} byte cap`, + { path, limit: this.config.maxBytes }, + ); + } + return { offset, length }; + } + /** + * Inspect the requested path itself before resolution follows its final + * component. Directory containment is checked separately by `list`. + */ + private async inspect( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise<{ root: FsTarget; workspaceRoot: string; entry: FsPathInfo }> { + if (path.length === 0) throw new RemoteError("gateway/bad-request", "path is required", {}); + const { workspaceRoot } = workspaceFileScope; + const root = await this.ctx.fs.resolve(workspaceRoot, { signal }); + // Gate on the path itself before anything follows it. + const entry = await this.ctx.fs.lstat(path, { cwd: workspaceRoot }, signal); + if (entry === undefined) { + throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path }); + } + return { root, workspaceRoot, entry }; + } + + /** Resolve an inspected path and refuse it unless the workspace contains it. */ + private async confine( + root: FsTarget, + workspaceRoot: string, + path: string, + signal: AbortSignal, + ): Promise { + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }); + if (!this.ctx.fs.contains(root, target)) { + throw new RemoteError( + "workspace-file/outside-workspace", + `"${path}" is outside the workspace`, + { path }, + ); + } + return target; + } + + /** + * All gates for a regular file, ending in the one stat that names its version + * and size. The stat re-checks what `lstat` saw: the file may have gone or + * changed kind in between. + */ + private async locateFile( + workspaceFileScope: WorkspaceFileScope, + path: string, + signal: AbortSignal, + ): Promise<{ target: FsTarget; info: FsInfo }> { + const { workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal); + if (entry.type !== "file") { + throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${entry.type}`, { + path, + kind: entry.type, + }); + } + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }); + const info = await this.ctx.fs.stat(target, signal); + if (info === undefined) { + throw new RemoteError("workspace-file/not-found", `no entry at "${path}"`, { path }); + } + if (info.type !== "file") { + throw new RemoteError("workspace-file/not-regular-file", `"${path}" is a ${info.type}`, { + path, + kind: info.type, + }); + } + return { target, info }; + } + + private statOf(target: FsTarget, info: FsInfo): WorkspaceFileStat { + return { + absolutePath: this.ctx.fs.processPath(target), + version: info.version, + ...(info.size === undefined ? {} : { bytes: info.size }), + }; + } + + /** Stream the file as text and cut the page, classifying the backend's non-text refusal. */ + private async cutPage( + target: FsTarget, + offset: number, + limit: number, + signal: AbortSignal, + path: string, + ): Promise { + try { + return await cutPage( + await this.ctx.fs.streamText(target, signal), + offset, + limit, + this.config.maxBytes, + path, + ); + } catch (error: unknown) { + if (isNotTextRefusal(error)) { + throw new RemoteError( + "workspace-file/not-text", + `"${path}" is not UTF-8 text`, + { path }, + { cause: error }, + ); + } + throw error; + } + } +} + +/** + * The backend's non-text refusal, recognized by its code alone: the error class + * belongs to whichever `dsh-fs` instance the provider loaded, so no class + * identity is shared across the package boundary. + */ +function isNotTextRefusal(error: unknown): boolean { + return ( + typeof error === "object" && error !== null && "code" in error && error.code === "FS_NOT_TEXT" + ); +} + +export default WorkspaceFiles; diff --git a/apps/web-ui/packages/api/workspace-files/src/types.ts b/apps/web-ui/packages/api/workspace-files/src/types.ts new file mode 100644 index 000000000..0db271038 --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/src/types.ts @@ -0,0 +1,177 @@ +/** + * Wire types of the `workspaceFiles` Remote namespace. Types only: generated + * Remote clients consume this module without Host runtime code. + * + * Two path vocabularies leave here, and each method uses exactly one: + * + * - `read`, `readBytes`, `stat`, and `changes` name a file by its absolute path in the + * filesystem's execution world, because their consumer is the Client + * resource system, whose `dsh-resource://file/session//` address carries that + * same path. + * - `list` speaks workspace paths — the same syntax its `path` argument accepts — + * because its consumer is a tree rooted at the workspace root. + * + * @module @deepseek-ai/dsh-api-workspace-files/types + */ + +// Import the protocol module so the declaration at the end of this file +// augments its error map rather than defining an unrelated ambient module. +import type {} from "@deepseek-ai/dsh-typert-protocol"; + +/** Identity and freshness of one workspace file, without its content. */ +export interface WorkspaceFileStat { + /** + * Absolute path of the file in the filesystem's execution world, symlinks + * resolved: `/`-separated on POSIX, drive-rooted with the platform separator + * on Windows. What a `dsh-resource://file/absolute/…` address carries, and + * what a `dsh-resource://file/session//…` address's + * workspace-relative path resolves to against that Session's root. + */ + readonly absolutePath: string; + /** Opaque freshness token at the time of the stat; never parsed. */ + readonly version: string; + /** Byte size of the complete file, when the backend reports it. */ + readonly bytes?: number; +} + +/** + * The line window one `read` returns. Lines are 1-based and end at `\n`; a + * final `\n` terminates the last line rather than starting an empty one. + */ +export interface WorkspaceFileRange { + /** First line of the page. Defaults to 1. */ + readonly offset?: number; + /** Largest number of lines on the page. Defaults to, and may not exceed, the configured `maxLines`. */ + readonly limit?: number; +} + +/** One page of a workspace text file as a Client reads it. */ +export interface WorkspaceFileText extends WorkspaceFileStat { + /** First line of the page, as requested. */ + readonly offset: number; + /** + * The page's lines joined by `\n`, without a terminator after the last one. + * Empty for a page past the file's last line and for a page holding one + * empty line; `lines` tells them apart. + */ + readonly text: string; + /** How many lines the page holds; `0` when `offset` lies past the file's last line. */ + readonly lines: number; + /** Whether the page includes the file's last line. */ + readonly eof: boolean; +} + +/** The byte window one `readBytes` returns. Offsets are 0-based. */ +export interface WorkspaceByteRange { + /** First byte of the window. Defaults to 0. */ + readonly offset?: number; + /** Largest number of bytes in the window. Defaults to, and may not exceed, the configured `maxBytes`. */ + readonly length?: number; +} + +/** Target resolution and optional byte range for one binary file read. */ +export interface WorkspaceByteReadOptions { + /** Byte window; omit to read the complete file under the configured `maxFileBytes` cap. */ + readonly range?: WorkspaceByteRange; + /** Base file, absolute or workspace-relative; resolve the relative target from its directory. */ + readonly baseFile?: string; +} + +/** + * One byte window of a workspace file as a Client reads it: raw bytes, no text + * decoding and no binary rejection. `bytes` is the complete file's size. + */ +export interface WorkspaceFileBytes< + Data extends Uint8Array = Uint8Array, +> extends WorkspaceFileStat { + /** First byte of the window, as requested. */ + readonly offset: number; + /** Native file bytes; empty at or past EOF. */ + readonly data: Data; + /** Whether the window includes the file's last byte. */ + readonly eof: boolean; +} + +/** One direct child of a listed workspace directory. */ +export interface WorkspaceDirectoryEntry { + /** Basename inside the listed directory. */ + readonly name: string; + /** + * What the child resolves to. A symlink reports the type of its destination, + * and `other` covers everything that is neither a regular file nor a + * directory; `read` still refuses a symlink, so `file` here is a listing fact, + * not a promise that the content is readable. + */ + readonly type: "file" | "directory" | "other"; + /** Byte size, present only for a regular file whose backend reports it. */ + readonly size?: number; +} + +/** Direct children of one workspace directory. */ +export interface WorkspaceDirectoryListing { + /** + * The listed directory as a workspace path, relative to the workspace root + * and empty for the root itself. A child's path is this value joined with + * {@link WorkspaceDirectoryEntry.name} by `/`. + */ + readonly path: string; + /** + * Direct children in the backend's stable name order, cut to the configured + * entry cap. Presentation order is the caller's choice. + */ + readonly entries: readonly WorkspaceDirectoryEntry[]; + /** Whether the entry cap dropped children from {@link entries}. */ + readonly truncated: boolean; +} + +/** + * Current target metadata after a filesystem invalidation. File consumers may + * ignore an already known version; directory consumers relist on every frame + * because a child's metadata can change without changing the directory version. + */ +export type WorkspaceFileChange = + | { + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string; + /** Opaque freshness token from the current stat; never parsed. */ + readonly version: string; + } + | { + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + readonly absolutePath: string; + /** The target was observed to be gone. */ + readonly absent: true; + }; + +/** + * One frame of a workspace file watch generation. `ready` confirms that the + * Host has initialized the target watcher; queued and live invalidations + * follow as `change` frames with current target metadata. + */ +export type WorkspaceFileWatchFrame = + { readonly kind: "ready" } | { readonly kind: "change"; readonly change: WorkspaceFileChange }; + +declare module "@deepseek-ai/dsh-typert-protocol" { + interface RemoteErrorDetailsMap { + /** The filesystem provider cannot initialize a watch for this target. */ + "workspace-file/watch-unsupported": { readonly path: string }; + /** No entry exists at that path inside the workspace. */ + "workspace-file/not-found": { readonly path: string }; + /** The directory listing or watch path resolves outside the Session's workspace root. */ + "workspace-file/outside-workspace": { readonly path: string }; + /** The requested page exceeds the configured byte cap; nothing is returned. */ + "workspace-file/too-large": { readonly path: string; readonly limit: number }; + /** The content read so far is not decodable UTF-8 text, or the page carries NUL bytes. */ + "workspace-file/not-text": { readonly path: string }; + /** The path is not a regular file, so it has no text to read. */ + "workspace-file/not-regular-file": { + readonly path: string; + readonly kind: "directory" | "symlink" | "other"; + }; + /** The path is not a directory, so it has no children to list. */ + "workspace-file/not-directory": { + readonly path: string; + readonly kind: "file" | "symlink" | "other"; + }; + } +} diff --git a/apps/web-ui/packages/api/workspace-files/tsdown.config.ts b/apps/web-ui/packages/api/workspace-files/tsdown.config.ts new file mode 100644 index 000000000..1462a154a --- /dev/null +++ b/apps/web-ui/packages/api/workspace-files/tsdown.config.ts @@ -0,0 +1,5 @@ +import { clientBundle } from "../../client/tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-api-workspace-files", ["lib/types/index.js"], { + hostPhase: true, +}); diff --git a/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts b/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts new file mode 100644 index 000000000..0310cbfd6 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/generated/remote.d.ts @@ -0,0 +1,45 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' +import type { BundleInfo, ChangeResult, InspectOptions, InstallBundleOptions, PluginInfo, PluginInstallCancellation, PluginInstallRequestId, PluginRegistries, PluginSpecInspection } from '@deepseek-ai/dsh-plugin-manager/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$706c7567696e4d616e61676572 { + cancelInstall: (requestId: PluginInstallRequestId) => Promise> + inspect: (spec: string, options?: InspectOptions, signal?: AbortSignal) => Promise> + installBundle: (spec: string, options?: InstallBundleOptions) => Promise> + listBundles: () => Promise> + listPlugins: () => Promise> + listVersionExemptions: () => Promise; warnings: string[]; }>> + registries: () => Promise> + removeBundle: (name: string) => Promise> + setBundleEnabled: (name: string, enabled: boolean) => Promise> + setPluginEnabled: (id: PluginEntryId, enabled: boolean) => Promise> + setVersionExemption: (packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean) => Promise> + waitForInstall: (requestId: PluginInstallRequestId) => Promise> + } + interface TypertRemoteMap { + 'pluginManager/cancelInstall': (requestId: PluginInstallRequestId) => Promise> + 'pluginManager/inspect': (spec: string, options?: InspectOptions, signal?: AbortSignal) => Promise> + 'pluginManager/installBundle': (spec: string, options?: InstallBundleOptions) => Promise> + 'pluginManager/listBundles': () => Promise> + 'pluginManager/listPlugins': () => Promise> + 'pluginManager/listVersionExemptions': () => Promise; warnings: string[]; }>> + 'pluginManager/registries': () => Promise> + 'pluginManager/removeBundle': (name: string) => Promise> + 'pluginManager/setBundleEnabled': (name: string, enabled: boolean) => Promise> + 'pluginManager/setPluginEnabled': (id: PluginEntryId, enabled: boolean) => Promise> + 'pluginManager/setVersionExemption': (packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean) => Promise> + 'pluginManager/waitForInstall': (requestId: PluginInstallRequestId) => Promise> + } + interface TypertRemoteNamespaceMap { + 'pluginManager': TypertRemoteNamespace$706c7567696e4d616e61676572 + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/boot/plugin-manager/generated/remote.js b/apps/web-ui/packages/boot/plugin-manager/generated/remote.js new file mode 100644 index 000000000..64ae6269e --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/generated/remote.js @@ -0,0 +1,733 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema$value ??= z.object({ + 'status': z.union([z.literal("cancelled"), z.literal("not-running"), z.literal("too-late")]).readonly(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema$value ??= z.union([z.undefined(), z.object({ + 'registry': z.union([z.literal(null), z.string()]).readonly().optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema$value ??= z.union([z.object({ + 'status': z.literal("accepted").readonly(), + 'kind': z.union([z.literal("registry"), z.literal("path"), z.literal("git"), z.literal("tarball")]).readonly(), + 'name': z.string().readonly().optional(), + 'version': z.string().readonly().optional(), + 'description': z.string().readonly().optional(), + 'bundle': z.union([z.literal(null), z.literal(false), z.literal(true)]).readonly(), + 'registry': z.union([z.literal(null), z.string()]).readonly(), + 'host': z.string().readonly().optional(), +}), z.object({ + 'status': z.literal("refused").readonly(), + 'problem': z.union([z.literal("network"), z.literal("unknown"), z.literal("invalid-spec"), z.literal("not-found"), z.literal("already-installed"), z.literal("not-a-package"), z.literal("not-a-bundle")]).readonly(), + 'reason': z.string().readonly(), + 'registries': z.array(z.union([z.literal(null), z.string()])).readonly().optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema$value ??= z.union([z.undefined(), z.object({ + 'enabled': z.boolean().optional(), + 'requestId': z.intersection(z.string(), z.unknown()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registry': z.union([z.literal(null), z.string()]).optional(), +})])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema$value ??= z.array(z.object({ + 'name': z.string(), + 'version': z.string().optional(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).optional(), + 'description': z.string().optional(), + 'enabled': z.boolean(), + 'installed': z.boolean(), + 'source': z.string().optional(), + 'optional': z.boolean(), + 'removable': z.boolean(), + 'readOnlyReason': z.union([z.literal("management-required"), z.literal("unaddressable")]).optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'rows': z.array(z.object({ + 'rowId': z.string(), + 'moduleName': z.string(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).optional(), + 'entryId': z.intersection(z.string(), z.unknown()).optional(), +})), + 'overrides': z.array(z.string()), +}))) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema$value ??= z.array(z.union([z.intersection(z.object({ + 'entryId': z.intersection(z.string(), z.unknown()).readonly(), + 'moduleName': z.string().readonly(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).readonly().optional(), + 'enabled': z.boolean().readonly(), + 'fiberPhase': z.union([z.literal(null), z.literal("failed"), z.literal("pending"), z.literal("active"), z.literal("loading"), z.literal("unloading")]).readonly(), +}), z.object({ + 'patchId': z.string(), + 'readOnlyReason': z.never().optional(), +})), z.intersection(z.object({ + 'entryId': z.intersection(z.string(), z.unknown()).readonly(), + 'moduleName': z.string().readonly(), + 'meta': z.object({ + 'title': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'description': z.union([z.string(), z.intersection(z.object({ + 'en': z.string().readonly(), +}), z.record(z.string(), z.string()).readonly())]).readonly().optional(), + 'icon': z.string().readonly().optional(), + 'error': z.string().readonly().optional(), +}).readonly().optional(), + 'enabled': z.boolean().readonly(), + 'fiberPhase': z.union([z.literal(null), z.literal("failed"), z.literal("pending"), z.literal("active"), z.literal("loading"), z.literal("unloading")]).readonly(), +}), z.object({ + 'patchId': z.never().optional(), + 'readOnlyReason': z.union([z.literal("management-required"), z.literal("unaddressable")]), +}))]))) +let _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema$value ??= z.object({ + 'exemptions': z.record(z.string(), z.array(z.string())), + 'warnings': z.array(z.string()), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema$value ??= z.object({ + 'registry': z.union([z.literal(null), z.string()]).readonly(), + 'fallbackRegistries': z.array(z.string()).readonly(), + 'resolved': z.union([z.literal(null), z.string()]).readonly(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema$value ??= z.string()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema$value ??= z.boolean()) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema$value ??= z.union([z.undefined(), z.literal(false), z.literal(true)])) +let _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema$value ??= z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})) +let _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema$value +const _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema = () => (_deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema$value ??= z.union([z.literal(null), z.object({ + 'changed': z.boolean(), + 'application': z.union([z.literal("cancelled"), z.literal("failed"), z.literal("applied"), z.literal("restart-required"), z.literal("overridden")]), + 'stage': z.union([z.literal("remove"), z.literal("install"), z.literal("enable")]), + 'target': z.string(), + 'enabled': z.boolean().optional(), + 'error': z.object({ + 'code': z.union([z.literal("management-required"), z.literal("unaddressable"), z.literal("unknown-plugin"), z.literal("invalid-spec"), z.literal("ambiguous-install"), z.literal("not-bundle"), z.literal("not-removable"), z.literal("stop-profile"), z.literal("bundle-in-use"), z.literal("stale-approval"), z.literal("incompatible-version"), z.literal("operation-error")]), + 'diagnostic': z.string().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'warnings': z.array(z.string()).optional(), + 'packageResult': z.object({ + 'exitCode': z.number(), + 'output': z.string(), + 'truncated': z.boolean(), + 'logPath': z.string(), + 'kind': z.union([z.literal("network"), z.literal("unknown"), z.literal("timeout"), z.literal("integrity"), z.literal("pnpm-missing"), z.literal("not-found"), z.literal("no-matching-version"), z.literal("disk-full"), z.literal("permission"), z.literal("build-blocked")]).optional(), + 'timedOut': z.boolean().optional(), + 'incompatible': z.array(z.object({ + 'name': z.string(), + 'version': z.string(), + 'runtimeVersion': z.string(), + 'peers': z.record(z.string(), z.string()), +})).optional(), +}).optional(), + 'bundle': z.string().optional(), + 'version': z.string().optional(), + 'pendingBuilds': z.array(z.string()).optional(), + 'approvedBuilds': z.array(z.string()).optional(), + 'registries': z.array(z.union([z.literal(null), z.string()])).optional(), + 'failedAt': z.union([z.literal("registry"), z.literal("spec-host")]).optional(), +})])) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-plugin-manager', + descriptors: [ + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/cancelInstall', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'cancelInstall', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'requestId', + wire: 'requestId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallRequestId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallCancellation', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_cancelInstall_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":600,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/inspect', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'inspect', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'spec', + wire: 'spec', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/inspect:spec', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_0$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#InspectOptions', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginSpecInspection', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_inspect_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":350,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/installBundle', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'installBundle', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'spec', + wire: 'spec', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/installBundle:spec', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_0$schema, + }, + }, + { + name: 'options', + wire: 'options', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#InstallBundleOptions', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_installBundle_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":473,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listBundles', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listBundles', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listBundles:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listBundles_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":280,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listPlugins', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listPlugins', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listPlugins:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listPlugins_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":256,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/listVersionExemptions', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'listVersionExemptions', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/listVersionExemptions:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_listVersionExemptions_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":232,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/registries', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'registries', + invocation: { kind: 'direct' }, + parameters: [ + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginRegistries', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_registries_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":334,"column":9}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/removeBundle', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'removeBundle', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/removeBundle:name', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_removeBundle_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":617,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setBundleEnabled', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'name', + wire: 'name', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled:name', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_0$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setBundleEnabled:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setBundleEnabled_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":452,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setPluginEnabled', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setPluginEnabled', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'id', + wire: 'id', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-host-plugin-inventory/types#PluginEntryId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_0$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setPluginEnabled:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_parameter_1$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setPluginEnabled_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":434,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'setVersionExemption', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'packageVersion', + wire: 'packageVersion', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:packageVersion', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_0$schema, + }, + }, + { + name: 'runtimeVersion', + wire: 'runtimeVersion', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:runtimeVersion', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_1$schema, + }, + }, + { + name: 'enabled', + wire: 'enabled', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:enabled', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_2$schema, + }, + }, + { + name: 'acceptRisk', + wire: 'acceptRisk', + source: 'json', + acceptsUndefined: true, + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/setVersionExemption:acceptRisk', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_parameter_3$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#ChangeResult', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_setVersionExemption_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":245,"column":3}, + }, + { + id: '@deepseek-ai/dsh-plugin-manager#pluginManager/waitForInstall', + service: 'pluginManager', + namespace: 'pluginManager', + method: 'waitForInstall', + invocation: { kind: 'direct' }, + parameters: [ + { + name: 'requestId', + wire: 'requestId', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager/types#PluginInstallRequestId', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_parameter_0$schema, + }, + }, + ], + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-plugin-manager#pluginManager/waitForInstall:result', + create: _deepseek_ai_dsh_plugin_manager_pluginManager_waitForInstall_result$schema, + }, + sourceLocation: {"file":"packages/boot/plugin-manager/src/index.ts","line":590,"column":9}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/boot/plugin-manager/package.json b/apps/web-ui/packages/boot/plugin-manager/package.json new file mode 100644 index 000000000..3c75d9f0b --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-plugin-manager", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./registry": { + "types": "./src/registry.ts", + "default": "./src/registry.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./operations": { + "types": "./src/operations.ts", + "default": "./src/operations.ts" + }, + "./tools": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts b/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts new file mode 100644 index 000000000..e9e5def4e --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/build-approval.ts @@ -0,0 +1,59 @@ +/** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { isAlias, isMap, isNode, isScalar, parseDocument, visit } from "yaml"; +import { ManagementFailure } from "./failure.ts"; +import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; + +async function readPolicy(dir: string) { + let text: string; + try { + text = await readFile(join(dir, "pnpm-workspace.yaml"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + text = "{}\n"; + } + const document = parseDocument(text); + if (document.errors[0] !== undefined) throw document.errors[0]; + if (!isMap(document.contents)) throw new Error("pnpm-workspace.yaml must be a YAML mapping"); + const builds = document.get("allowBuilds"); + if (builds !== undefined && !isMap(builds)) throw new Error("allowBuilds must be a YAML mapping"); + visit(builds ?? null, (_key, node) => { + if (isAlias(node) || (isNode(node) && "anchor" in node && node.anchor)) { + throw new Error("allowBuilds must not contain YAML anchors or aliases"); + } + }); + const pending = isMap(builds) + ? builds.items.flatMap(({ key, value }) => + isScalar(key) && + typeof key.value === "string" && + !/[*?]/.test(key.value) && + isScalar(value) && + value.value === "set this to true or false" + ? [key.value] + : [], + ) + : []; + return { document, pending }; +} + +/** Read package names left undecided by pnpm 11, including after installation cleanup. + * @param dir Current profile directory. + * @returns Exact package names awaiting a build decision; wildcard rules are excluded. + */ +export async function readPendingBuilds(dir: string): Promise { + return (await readPolicy(dir)).pending; +} + +/** Persist approval without running scripts; the caller holds the profile manifest lock. + * @param dir Current profile directory. + * @param names Explicit package names from the pending build list. + * @throws If a name is no longer pending or allowBuilds contains YAML anchors or aliases; no approvals are written. + */ +export async function approveBuilds(dir: string, names: readonly string[]): Promise { + const { document, pending } = await readPolicy(dir); + if (names.some((name) => !pending.includes(name))) throw new ManagementFailure("stale-approval"); + if (names.length === 0) return; + for (const name of names) document.setIn(["allowBuilds", name], true); + await writeFileAtomic(join(dir, "pnpm-workspace.yaml"), String(document), { mode: 0o600 }); +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/failure.ts b/apps/web-ui/packages/boot/plugin-manager/src/failure.ts new file mode 100644 index 000000000..de88b2ced --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/failure.ts @@ -0,0 +1,34 @@ +/** Localizable rejections shared by profile management operations. */ +import type { PluginCompatibility } from "@deepseek-ai/dsh-app-boot"; +import type { IncompatiblePlugin, ManagementError } from "./types.ts"; + +/** Expected management rejection; presentation belongs to the caller's locale. */ +export class ManagementFailure extends Error { + /** Code rendered by the caller's locale dictionary. */ + readonly code: ManagementError["code"]; + /** The packages an `incompatible-version` rejection names. */ + readonly incompatible: IncompatiblePlugin[] | undefined; + /** + * @param code Localizable management rejection. + * @param incompatible Packages the running DSH version rejects, for `incompatible-version`. + */ + constructor(code: ManagementError["code"], incompatible?: IncompatiblePlugin[]) { + super(code); + this.code = code; + this.incompatible = incompatible; + } +} + +/** + * Drop the exemption status from an unexempted compatibility result. + * @param issue Result whose exemption is not active. + * @returns The package, runtime, and rejected peer ranges. + */ +export function incompatiblePlugin(issue: PluginCompatibility): IncompatiblePlugin { + return { + name: issue.name, + version: issue.version, + runtimeVersion: issue.runtimeVersion, + peers: issue.peers, + }; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts b/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts new file mode 100644 index 000000000..52441485a --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/github-connection.ts @@ -0,0 +1,94 @@ +/** Bounded GitHub repository checks using the installer's Git configuration and environment. */ +import { mkdir, mkdtemp, open } from "node:fs/promises"; +import { join } from "node:path"; +import { execa } from "execa"; +import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess"; +import { classifyInstallFailure } from "./install-failure.ts"; +import type { ParsedInstallSpec } from "./install-spec.ts"; +import type { PackageResult } from "./types.ts"; + +/** Deadline, diagnostics and cancellation for one read-only GitHub check. */ +export interface GithubConnectionOptions { + timeoutMs: number; + outputBytes: number; + signal: AbortSignal; + /** The application-owned package manager's environment, also used by its Git children. */ + env?: Readonly>; +} + +/** + * Check a GitHub repository before pnpm starts, without downloading or building its package. + * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting. + * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback. + * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. + * @param dir The profile directory where installation runs. + * @param options The connection deadline, output bound and operation cancellation. + * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec. + */ +export async function checkGithubConnection( + spec: ParsedInstallSpec, + dir: string, + options: GithubConnectionOptions, +): Promise { + if (spec.kind !== "git") return undefined; + const host = spec.host.toLowerCase().replace(/:.*$/, ""); + if (host !== "github.com" && !host.endsWith(".github.com")) return undefined; + const repository = spec.spec + .replace(/#.*$/s, "") + .replace(/^github:/i, "https://github.com/") + .replace(/^gist:/i, "https://gist.github.com/") + .replace(/^git\+/i, ""); + const logRoot = join(dir, ".plugin-manager", "logs"); + await mkdir(logRoot, { recursive: true, mode: 0o700 }); + const logDir = await mkdtemp(join(logRoot, "github-connection-")); + const logPath = join(logDir, "git.log"); + const log = await open(logPath, "ax+", 0o600); + try { + const result = await execa( + "git", + ["-c", "credential.helper=", "ls-remote", "--", repository, "HEAD"], + { + cwd: dir, + env: { + ...scrubbedParentEnv(), + ...options.env, + LC_ALL: "C", + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", + }, + extendEnv: false, + stdin: "ignore", + stdout: "ignore", + stderr: { file: logPath, append: true }, + buffer: false, + reject: false, + timeout: options.timeoutMs, + cancelSignal: options.signal, + killDescendants: true, + killSignal: "SIGKILL", + }, + ); + if (!result.failed) return undefined; + if (result.timedOut) + await log.write( + `dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`, + ); + if ((await log.stat()).size === 0) + await log.write(result.shortMessage ?? "GitHub connection check failed"); + const { size } = await log.stat(); + const bytes = Buffer.alloc(Math.min(size, options.outputBytes)); + await log.read(bytes, 0, bytes.length, size - bytes.length); + const output = bytes.toString("utf8"); + return { + exitCode: result.exitCode ?? (result.code === "ENOENT" ? 127 : 1), + output, + truncated: size > options.outputBytes, + logPath, + kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), + }; + } finally { + await log.close(); + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/index.ts b/apps/web-ui/packages/boot/plugin-manager/src/index.ts new file mode 100644 index 000000000..7853b2643 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/index.ts @@ -0,0 +1,1191 @@ +/** Current-profile plugin and bundle management over shared dsh plugin operations. */ +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; +import { Context } from "@deepseek-ai/cordis"; +import type { EntryOptions } from "@deepseek-ai/cordis-plugin-loader"; +import type { PatchOptions } from "@deepseek-ai/cordis-plugin-include"; +import z from "@deepseek-ai/schemastery"; +import { TypertRemoteService, Remote } from "@deepseek-ai/dsh-typert-protocol"; +import { pluginEntryId, readPluginInventory } from "@deepseek-ai/dsh-host-plugin-inventory"; +import { + readPluginMeta, + readProfileManifest, + resolveBundleDir, + loadOverlayPatches, + composeEntries, + reconcileProfilePatches, + readProfilePatches, + OPTIONAL_BUNDLES, + bundlePatchPaths, + evaluatePluginCompatibility, + readProfileCompatibility, + readProfileVersionExemptions, + setProfileVersionExemption, + PROFILE_COMPATIBILITY_FILENAME, +} from "@deepseek-ai/dsh-app-boot"; +import type {} from "@deepseek-ai/dsh-hmr"; +import type { ProfileContext, ProfileManifest } from "@deepseek-ai/dsh-app-boot"; +import { + bundleManifest, + readProfileRegistry, + registryArguments, + runProfilePnpm, + saveManifest, + viewProfilePackage, +} from "./operations.ts"; +import { classifyInstallFailure } from "./install-failure.ts"; +import { + dependencySpec, + InvalidInstallSpecError, + parseInstallSpec, + type ParsedInstallSpec, +} from "./install-spec.ts"; +import { + attributeFailure, + normalizeRegistry, + NPMMIRROR_REGISTRY, + registryPlan, +} from "./registry.ts"; +import { writePluginEnabled } from "./patch.ts"; +import { incompatiblePlugin, ManagementFailure } from "./failure.ts"; +import { approveBuilds, readPendingBuilds } from "./build-approval.ts"; +import { checkGithubConnection } from "./github-connection.ts"; +import type { + BundleInfo, + BundleRowInfo, + ChangeResult, + InspectOptions, + InstallBundleOptions, + ManagementError, + PackageResult, + PluginChange, + PluginEntryId, + PluginInfo, + PluginInspectProblem, + PluginInstallCancellation, + PluginInstallProgress, + PluginInstallRequestId, + PluginRegistries, + PluginSpecInspection, + Registry, +} from "./types.ts"; +export type * from "./types.ts"; +export { classifyInstallFailure, type InstallFailureFacts } from "./install-failure.ts"; +export { + InvalidInstallSpecError, + parseInstallSpec, + type ParsedInstallSpec, +} from "./install-spec.ts"; + +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ +export interface Config { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ + pnpmCommand?: string; + /** Maximum retained package-operation diagnostic bytes. */ + outputBytes?: number; + /** Maximum time to wait for another process's profile package operation. */ + lockWaitMs?: number; + /** Bound on one registry lookup an inspection runs, in milliseconds. */ + inspectTimeoutMs?: number; + /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ + githubConnectionTimeoutMs?: number; + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number; + /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ + registry?: string; + /** + * Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package. + * A registry outside this set and `registry` is asked alone, and so is the one pnpm's own configuration names + * unless that is npm's own registry or one of these. + */ + fallbackRegistries?: string[]; +} + +/** An http(s) URL, as pnpm's `--registry` takes it. */ +const REGISTRY_URL = /^https?:\/\/\S+$/; + +const protectedModules = new Set([ + "@deepseek-ai/dsh-plugin-manager", + "@deepseek-ai/cordis-plugin-loader", + "@deepseek-ai/cordis-plugin-include", + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-host-webserver", + "@deepseek-ai/dsh-client-modules", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-host-plugin-inventory", + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/cordis-plugin-timer", + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-host-frontend-static", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-hmr", +]); + +/** The profile files an installation writes and a failed or cancelled one restores. */ +const RESTORED_FILES = ["package.json", "pnpm-lock.yaml"] as const; + +/** pnpm's colour escapes, which a JSON answer may be wrapped in. */ +const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g; + +/** Flatten only the groups addressable by the profile's patch composer. */ +function flatten(rows: EntryOptions[]): EntryOptions[] { + return rows.flatMap((row) => [ + row, + ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []), + ]); +} + +/** Preserve the exact observed diagnostic, including non-Error failures. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */ +function managementError(error: unknown): ManagementError { + if (!(error instanceof ManagementFailure)) + return { code: "operation-error", diagnostic: messageOf(error) }; + return { + code: error.code, + ...(error.incompatible === undefined ? {} : { incompatible: error.incompatible }), + }; +} + +/** The caller stopped an installation; its files are restored before this is thrown. */ +class InstallCancelledError extends Error { + constructor() { + super("Installation cancelled"); + this.name = "InstallCancelledError"; + } +} + +/** One installation the manager owns until its call settles. */ +interface InstallControl { + readonly abort: AbortController; + /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */ + phase: "installing" | "applying"; + /** The active install's result, shared with clients recovering a lost response. */ + result: Promise; +} + +/** A manifest field that is a string, when the manifest carries one. */ +function stringField(manifest: object, field: string): string | undefined { + const value = (manifest as Record)[field]; + return typeof value === "string" ? value : undefined; +} + +/** The fields of the dsh installation's own manifest the manager reads. */ +interface InstallationManifest { + dependencies?: Record; +} + +/** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */ +function inspectionOf( + kind: "registry" | "path", + manifest: object, + registry: Registry, +): Extract { + const dsh = (manifest as { dsh?: unknown }).dsh; + const declared = + typeof dsh === "object" && dsh !== null ? (dsh as { bundle?: unknown }) : undefined; + const bundle = + declared !== undefined && typeof declared.bundle === "object" && declared.bundle !== null; + const name = stringField(manifest, "name"); + const version = stringField(manifest, "version"); + const description = stringField(manifest, "description"); + return { + status: "accepted", + kind, + bundle, + registry, + ...(name === undefined ? {} : { name }), + ...(version === undefined ? {} : { version }), + ...(description === undefined || description === "" ? {} : { description }), + }; +} + +function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection { + return { status: "refused", problem, reason }; +} + +/** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */ +function printedError(printed: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(printed || "null"); + } catch { + return ""; /* not JSON: nothing pnpm printed as a refusal */ + } + const error = + typeof parsed === "object" && parsed !== null + ? (parsed as { error?: unknown }).error + : undefined; + if (typeof error !== "object" || error === null) return ""; + const { code, message } = error as { code?: unknown; message?: unknown }; + return [code, message].filter((part): part is string => typeof part === "string").join(" "); +} + +/** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */ +function parsedForRegistry(spec: string): ParsedInstallSpec { + try { + return parseInstallSpec(spec); + } catch (error) { + /* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error; + return { kind: "registry", spec, name: spec }; + } +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Persistent management of the current profile's composition and packages. */ + pluginManager: PluginManager; + } +} + +/** Manage profile files and apply their declared reload lifecycle. */ +export class PluginManager extends TypertRemoteService { + static inject = ["loader", "profileContext"]; + static Config: z = z.object({ + pnpmCommand: z.string().default("pnpm"), + outputBytes: z.number().step(1).min(1).default(16384), + lockWaitMs: z.number().step(1).min(0).default(120000), + inspectTimeoutMs: z.number().step(1).min(1000).default(20000), + githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000), + idleTimeoutMs: z.number().step(1).min(1000).default(600000), + registry: z.string().pattern(REGISTRY_URL), + fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), + }); + /** Management bundles remain protected if their files become unreadable. */ + private readonly managementBundles = new Set(); + private readonly ownerEntryId: string | undefined; + private readonly packageOperations = new Set>(); + private readonly profile: ProfileContext; + private readonly outputBytes: number; + private readonly lockWaitMs: number; + private readonly inspectTimeoutMs: number; + private readonly githubConnectionTimeoutMs: number; + private readonly idleTimeoutMs: number; + private readonly pnpmCommand: string; + private readonly configuredRegistries: Omit; + private readonly ownerContext: Context; + private readonly abort = new AbortController(); + /** Installations by request id, from their call until it settles. */ + private readonly installs = new Map(); + + constructor(ctx: Context, config: Config) { + super(ctx, "pluginManager"); + this.ownerEntryId = ctx.fiber.entry?.id; + this.ownerContext = ctx; + this.profile = ctx.profileContext; + for (const name of this.profile.startedBundles) this.protectsManager(name); + this.outputBytes = (config as Required).outputBytes; + this.lockWaitMs = (config as Required).lockWaitMs; + this.inspectTimeoutMs = (config as Required).inspectTimeoutMs; + this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs; + this.idleTimeoutMs = (config as Required).idleTimeoutMs; + this.pnpmCommand = (config as Required).pnpmCommand; + this.configuredRegistries = { + registry: config.registry === undefined ? null : normalizeRegistry(config.registry), + fallbackRegistries: (config as Required).fallbackRegistries.map(normalizeRegistry), + }; + ctx.effect( + () => async () => { + this.abort.abort(); + await Promise.allSettled([...this.packageOperations]); + }, + "plugin-manager: package cancellation", + ); + } + + /** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ + @Remote + listVersionExemptions(): { exemptions: Record; warnings: string[] } { + const { exemptions, warnings } = readProfileCompatibility(this.profile.dir); + return { exemptions, warnings }; + } + + /** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ + @Remote + setVersionExemption( + packageVersion: string, + runtimeVersion: string, + enabled: boolean, + acceptRisk?: boolean, + ): Promise { + return this.change( + (result) => + this.configure(async () => { + await setProfileVersionExemption( + this.profile.dir, + packageVersion, + runtimeVersion, + enabled, + acceptRisk === true, + ); + result.warnings = await this.reload(); + }), + { stage: "enable", target: packageVersion, enabled }, + "bundle", + ); + } + + /** Read current plugins, including why a row cannot be changed through the profile patch. + * @returns Current runtime entries with persistent patch targets. + */ + @Remote + async listPlugins(): Promise { + const rows = flatten(composeEntries([readProfilePatches("dsh", this.profile)])); + const snapshot = await readPluginInventory(this.ctx); + return snapshot.entries.map((entry) => { + const actual = [...this.ctx.loader.entries()].find((row) => row.id === entry.entryId); + const candidates = rows.filter((row) => row.id === actual?.options.id); + const candidate = candidates[0]; + if (protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId) { + return { ...entry, readOnlyReason: "management-required" as const }; + } + if ( + candidate === undefined || + candidates.length > 1 || + candidate.name !== entry.moduleName || + actual?.parent.tree.ctx.fiber.entry?.id !== "include" + ) { + return { ...entry, readOnlyReason: "unaddressable" as const }; + } + return { ...entry, patchId: candidate.id }; + }); + } + + /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional + * display metadata, activation selections, whether the installation offers the bundle, and removal availability. + */ + @Remote + listBundles(): Promise { + const manifest = readProfileManifest("dsh", this.profile.dir); + const exemptions = readProfileVersionExemptions(this.profile.dir); + const selected = manifest.dsh?.profile?.bundles ?? []; + const recorded = manifest.dependencies ?? {}; + const dependencies = Object.keys(recorded); + const installation = JSON.parse( + readFileSync(this.profile.installAnchor, "utf8"), + ) as InstallationManifest; + const names = [ + ...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})]), + ]; + const bundles: BundleInfo[] = []; + for (const name of names) { + const installed = dependencies.includes(name); + const optional = OPTIONAL_BUNDLES.includes(name); + const enabled = selected.includes(name); + const shipped = Object.hasOwn(installation.dependencies ?? {}, name); + // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names, + // like one it forbids removing, is not the loaded copy. + const owned = installed && !shipped; + // A selection neither the profile nor the installation holds, such as a retired bundle, is removed by deselecting it. + const removable = owned || (enabled && !installed && !shipped); + const sourceOf = (packageName?: string): { source?: string } => + owned + ? { + source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName), + } + : {}; + const readOnlyReason = this.protectsManager(name) + ? ("management-required" as const) + : undefined; + try { + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (info === undefined) { + if (enabled) + bundles.push({ + name, + ...sourceOf(), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + error: { code: "not-bundle" }, + rows: [], + overrides: [], + }); + continue; + } + const compatibility = evaluatePluginCompatibility(info, exemptions); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]); + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const meta = readPluginMeta( + info.name ?? name, + pathToFileURL(join(dir, "package.json")).href, + ); + bundles.push({ + name, + ...(info.version === undefined ? {} : { version: info.version }), + ...(info.description === undefined || info.description === "" + ? {} + : { description: info.description }), + ...(meta === undefined ? {} : { meta }), + ...sourceOf(info.name), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + ...this.declaredRows(name, info), + }); + } catch (error) { + if (enabled || installed) { + bundles.push({ + name, + ...sourceOf(), + enabled, + installed, + optional, + removable: removable && readOnlyReason === undefined, + ...(readOnlyReason === undefined ? {} : { readOnlyReason }), + error: managementError(error), + rows: [], + overrides: [], + }); + } + } + } + return Promise.resolve(bundles); + } + + /** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. + * @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now. + */ + @Remote + async registries(): Promise { + return { + ...this.configuredRegistries, + fallbackRegistries: [...this.configuredRegistries.fallbackRegistries], + resolved: await readProfileRegistry(this.profile.dir, { + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + timeoutMs: this.inspectTimeoutMs, + }), + }; + } + + /** Read what a spec names before installing it. + * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball. + * @param options The registry asked first. + * @param signal Ends a registry lookup early. + * @returns The package the spec names, or why it is refused. + */ + @Remote + async inspect( + spec: string, + options?: InspectOptions, + signal?: AbortSignal, + ): Promise { + let parsed; + try { + parsed = parseInstallSpec(spec); + } catch (error) { + /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */ + if (!(error instanceof InvalidInstallSpecError)) throw error; + return refused("invalid-spec", error.reason); + } + const manifest = readProfileManifest("dsh", this.profile.dir); + const installation = JSON.parse( + readFileSync(this.profile.installAnchor, "utf8"), + ) as InstallationManifest; + const known = new Set([ + ...(manifest.dsh?.profile?.bundles ?? []), + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(installation.dependencies ?? {}), + ]); + const plan = registryPlan(options?.registry, await this.registries()); + const registry = plan[0] as Registry; + switch (parsed.kind) { + case "git": + return { status: "accepted", kind: "git", bundle: null, registry, host: parsed.host }; + case "tarball": + if (parsed.path !== undefined && !existsSync(parsed.path)) + return refused("not-a-package", "the tarball does not exist"); + return { + status: "accepted", + kind: "tarball", + bundle: null, + registry, + ...(parsed.host === undefined ? {} : { host: parsed.host }), + }; + case "path": { + if (!existsSync(parsed.path)) return refused("not-a-package", "the path does not exist"); + let read: object; + try { + read = JSON.parse(await readFile(join(parsed.path, "package.json"), "utf8")) as object; + } catch (error) { + return refused( + "not-a-package", + `no readable package.json at the path: ${messageOf(error)}`, + ); + } + const inspection = inspectionOf("path", read, registry); + if (inspection.name === undefined) + return refused("not-a-package", "the package.json names no package"); + if (known.has(inspection.name)) + return refused("already-installed", `${inspection.name} is already installed`); + if (!inspection.bundle) + return refused("not-a-bundle", `${inspection.name} declares no dsh.bundle`); + return inspection; + } + case "registry": { + if (known.has(parsed.name)) + return refused("already-installed", `${parsed.name} is already installed`); + const registries: Registry[] = []; + const refusedBy = ( + problem: PluginInspectProblem, + reason: string, + ): PluginSpecInspection => ({ status: "refused", problem, reason, registries }); + for (const current of plan) { + registries.push(current); + const view = await viewProfilePackage(this.profile.dir, spec.trim(), { + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + timeoutMs: this.inspectTimeoutMs, + ...(signal === undefined ? {} : { signal }), + registry: current, + }); + const printed = view.stdout.replace(ANSI_SEQUENCE, "").trim(); + if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) { + // pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr. + const log = [ + view.stderr.trim(), + printedError(printed), + view.cause === undefined ? "" : messageOf(view.cause), + ] + .filter(Boolean) + .join("\n"); + const kind = classifyInstallFailure({ + log, + timedOut: view.timedOut, + ...(view.cause === undefined ? {} : { cause: view.cause }), + }); + // A lookup the caller dropped is not carried to the next registry. + if ( + registries.length < plan.length && + signal?.aborted !== true && + attributeFailure(kind, log, parsed) === "registry" + ) + continue; + const reason = view.timedOut + ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` + : log || printed || `pnpm view exited with ${String(view.exitCode)}`; + if (kind === "not-found" || kind === "no-matching-version") + return refusedBy("not-found", reason); + if (kind === "network" || kind === "timeout") return refusedBy("network", reason); + return refusedBy("unknown", reason); + } + let answer: unknown; + try { + answer = JSON.parse(printed || "null"); + } catch (error) { + return refusedBy("unknown", `unreadable pnpm view output: ${messageOf(error)}`); + } + // A range answers one object per matching version, oldest first. + const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer; + if (typeof latest !== "object" || latest === null) + return refusedBy("unknown", "pnpm view answered no package"); + const inspection = inspectionOf("registry", latest, current); + const named = + inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection; + if (!named.bundle) + return refusedBy("not-a-bundle", `${named.name} declares no dsh.bundle`); + return named; + } + /* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */ + throw new Error("no registry was asked"); + } + } + } + + /** Persist a plugin entry's desired enablement and apply it on live profiles. + * @param id Loader entry identity returned by listPlugins. + * @param enabled Whether the plugin should run. + * @returns Saved and runtime outcomes, including higher-priority overrides. + */ + @Remote + setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise { + return this.change( + (result) => + this.configure(async () => { + const row = (await this.listPlugins()).find((item) => item.entryId === id); + if (row === undefined) throw new ManagementFailure("unknown-plugin"); + if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason); + await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled); + result.warnings = await this.reload(enabled ? [row.patchId] : []); + const current = (await this.listPlugins()).find((item) => item.entryId === id); + return current?.enabled !== enabled && this.ownerContext.get("hmr") !== undefined + ? "overridden" + : undefined; + }), + { stage: "enable", target: id, enabled }, + "plugin", + ); + } + + /** Select or remove a bundle layer while retaining installed dependencies. + * @param name Bundle package name. + * @param enabled Whether the bundle contributes its patch layer. + * @returns Persisted and runtime outcomes. + */ + @Remote + setBundleEnabled(name: string, enabled: boolean): Promise { + return this.change( + (result) => + this.configure(async () => { + await this.selectBundle(name, enabled); + if (enabled) await this.refreshPackages(); + result.warnings = await this.reload( + enabled ? this.bundleRows(name).map((row) => row.id) : [], + ); + if (!enabled && this.ownerContext.get("hmr") !== undefined) await this.refreshPackages(); + }), + { stage: "enable", target: name, enabled }, + "bundle", + ); + } + + /** + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run + * that fails, is cancelled, or adds a package without a bundle patch restores + * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. + * @param spec One package spec, including local paths relative to the invocation directory. + * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, + * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first. + * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome. + */ + @Remote + installBundle(spec: string, options?: InstallBundleOptions): Promise { + const requestId = options?.requestId; + const control: InstallControl = { + abort: new AbortController(), + phase: "installing", + result: Promise.resolve(null), + }; + const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted; + if (requestId !== undefined) this.installs.set(requestId, control); + const announce = ( + phase: PluginInstallProgress["phase"], + attempt?: PluginInstallProgress["attempt"], + ): void => { + if (requestId !== undefined) + this.ownerContext.emit("plugin-manager/install-state", { + requestId, + phase, + ...(attempt === undefined ? {} : { attempt }), + }); + }; + const result = this.change( + async (result) => { + if (spec.trim() === "" || spec.startsWith("-")) throw new ManagementFailure("invalid-spec"); + if (stopped()) throw new InstallCancelledError(); + if (options?.approvedBuilds !== undefined) { + await approveBuilds(this.profile.dir, options.approvedBuilds); + result.approvedBuilds = options.approvedBuilds; + } + const files = await this.readRestoredFiles(); + const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {}; + let name: string; + let version: string | undefined; + try { + result.registries = []; + const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, { + timeoutMs: this.githubConnectionTimeoutMs, + outputBytes: this.outputBytes, + signal: AbortSignal.any([this.abort.signal, control.abort.signal]), + ...(this.profile.packageManager?.env === undefined + ? {} + : { env: this.profile.packageManager.env }), + }); + this.packageOperations.add(connection); + let connectionFailure: PackageResult | undefined; + try { + connectionFailure = await connection; + } finally { + this.packageOperations.delete(connection); + } + if (stopped()) throw new InstallCancelledError(); + if (connectionFailure?.kind === "network" || connectionFailure?.kind === "timeout") { + result.packageResult = connectionFailure; + result.failedAt = "spec-host"; + throw new Error(connectionFailure.output); + } + // The last run is the result's; the registries asked stay listed whatever the outcome. + const plan = registryPlan(options?.registry, await this.registries()); + let run: PackageResult | undefined; + for (const [index, registry] of plan.entries()) { + if (index > 0) await this.restoreFiles(files); + // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. + if (stopped()) throw new InstallCancelledError(); + result.registries.push(registry); + announce("installing", { registry, index: index + 1, total: plan.length }); + run = await this.runPnpm( + ["add", spec, ...registryArguments(registry)], + control.abort.signal, + requestId, + ); + result.packageResult = run; + if (stopped()) throw new InstallCancelledError(); + // A compatibility refusal is the package's own answer, so no other registry is asked. + if (run.incompatible !== undefined) + throw new ManagementFailure("incompatible-version", run.incompatible); + // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. + if (run.exitCode === 0 && run.timedOut !== true) break; + /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ + if (run.kind === undefined) break; + // What the last failed run could not reach; a later run that succeeds leaves nothing to say. + delete result.failedAt; + // A run this manager terminated got no answer from the registry at all, so no registry explains it. + if (run.timedOut === true) break; + const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)); + if (failedAt !== "other") result.failedAt = failedAt; + if (failedAt !== "registry" || index === plan.length - 1) break; + } + /* v8 ignore next -- the plan is never empty, so a run always settled */ + if (run === undefined) throw new Error("no registry was asked"); + // A terminated run reports no usable exit status, so neither its files nor its bundle are trusted. + const succeeded = run.exitCode === 0 && run.timedOut !== true; + if (succeeded) delete result.failedAt; + if (!succeeded) { + // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval. + try { + result.pendingBuilds = await readPendingBuilds(this.profile.dir); + } catch (error) { + this.ownerContext.logger.warn( + "Could not read pending build approvals after pnpm failed", + error, + ); + } + throw new Error(run.output); + } + const after = readProfileManifest("dsh", this.profile.dir).dependencies ?? {}; + const installed = Object.keys(after).filter((name) => before[name] !== after[name]); + // Registry retries can retain the saved range after a partial installation. + if (installed.length === 0) + installed.push( + ...Object.keys(after).filter((name) => spec === name || spec.startsWith(`${name}@`)), + ); + const target = installed[0]; + if (installed.length !== 1 || target === undefined) + throw new ManagementFailure("ambiguous-install"); + name = target; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (manifest?.dsh?.bundle === undefined) throw new ManagementFailure("not-bundle"); + const compatibility = evaluatePluginCompatibility( + manifest, + readProfileVersionExemptions(this.profile.dir), + ); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [ + incompatiblePlugin(compatibility), + ]); + for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) + loadOverlayPatches("dsh", file); + version = manifest.version; + } catch (error) { + // pnpm has exited by now, so the files it rewrote go back as they were. + await this.restoreFiles(files); + throw error; + } + control.phase = "applying"; + announce("applying"); + result.bundle = name; + if (version !== undefined) result.version = version; + result.target = name; + result.stage = "enable"; + return this.configure(async () => { + if (options?.enabled !== false) await this.selectBundle(name, true); + if (Object.hasOwn(before, name)) return "restart-required"; + await this.refreshPackages(); + if (options?.enabled !== false) result.warnings = await this.reload(); + }); + }, + { stage: "install", target: spec, enabled: options?.enabled !== false }, + "install", + ); + control.result = result; + return result.finally(() => { + if (requestId !== undefined) this.installs.delete(requestId); + }); + } + + /** Recover the result of an active installation without cancelling it. + * @param requestId The id supplied when installation started. + * @returns The installation's outcome after it settles, or null if no active request has that id. + * Completed results are not retained; null establishes neither success nor cancellation. + */ + @Remote + async waitForInstall(requestId: PluginInstallRequestId): Promise { + return this.installs.get(requestId)?.result ?? null; + } + + /** Stop an installation this manager owns and wait until its files are back. + * @param requestId The id the installation was started with. + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being + * applied, `not-running` for any other id. + */ + @Remote + async cancelInstall(requestId: PluginInstallRequestId): Promise { + const control = this.installs.get(requestId); + if (control === undefined) return { status: "not-running" }; + if (control.phase === "applying") return { status: "too-late" }; + this.ownerContext.emit("plugin-manager/install-state", { requestId, phase: "cancelling" }); + control.abort.abort(); + /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */ + await control.result.then( + () => undefined, + () => undefined, + ); + return { status: "cancelled" }; + } + + /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path; a selected name no + * dependency holds is only deselected. + * @param name Installed dependency or selected bundle name. + * @returns Removal diagnostics and the remaining profile state. + */ + @Remote + removeBundle(name: string): Promise { + return this.change( + async (result) => { + const installed = await this.configure(async () => { + const bundle = (await this.listBundles()).find((item) => item.name === name); + if (bundle === undefined || !bundle.removable) + throw new ManagementFailure("not-removable"); + if ( + this.ownerContext.get("hmr") === undefined && + (this.profile.startedBundles.includes(name) || + (bundle.error === undefined && + this.bundleRows(name).some((row) => + [...this.ctx.loader.entries()].some( + (entry) => entry.options.id === row.id && entry.fiber !== undefined, + ), + ))) + ) { + throw new ManagementFailure("stop-profile"); + } + const contributions = bundle.error === undefined ? this.bundleRows(name) : []; + if (bundle.enabled) { + await this.selectBundle(name, false); + result.warnings = await this.reload(); + } + if ( + [...this.ctx.loader.entries()].some( + (entry) => + entry.fiber?.uid != null && + contributions.some( + (row) => row.id === entry.options.id && row.name === entry.options.name, + ), + ) + ) { + throw new ManagementFailure("bundle-in-use"); + } + return bundle.installed; + }); + if (!installed) return; + result.packageResult = await this.runPnpm(["remove", name]); + if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { + throw new Error(result.packageResult.output); + } + await this.configure(() => this.refreshPackages()); + }, + { stage: "remove", target: name }, + "remove", + ); + } + + /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */ + private declaredRows( + name: string, + info: ProfileManifest, + ): Pick { + const bundle = info.dsh?.bundle; + /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */ + if (bundle === undefined) return { rows: [], overrides: [] }; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap((file) => + loadOverlayPatches("dsh", file), + ); + // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. + const live = new Map(); + for (const entry of this.ctx.loader.entries()) { + /* v8 ignore next -- the Loader gives every entry an id before it is listed */ + if (typeof entry.options.id === "string") + live.set(entry.options.id, { + entryId: pluginEntryId(entry.id), + baseUrl: entry.parent.tree.ctx.baseUrl, + }); + } + const rows: BundleRowInfo[] = []; + const packages = this.ctx.get("pluginPackages"); + for (const row of flatten( + composeEntries([patches.filter((item) => item.insert !== undefined)]), + )) { + if (typeof row.id !== "string" || typeof row.name !== "string") continue; + const active = live.get(row.id); + const entryId = active?.entryId; + const base = active?.baseUrl ?? pathToFileURL(join(dir, "package.json")).href; + const meta = packages?.metaOf(row.name, base); + rows.push({ + rowId: row.id, + moduleName: row.name, + ...(entryId === undefined ? {} : { entryId }), + ...(meta === undefined ? {} : { meta }), + }); + } + const declared = new Set(rows.map((row) => row.rowId)); + const overrides = [ + ...new Set( + patches.flatMap((item) => + item.insert === undefined && typeof item.id === "string" && !declared.has(item.id) + ? [item.id] + : [], + ), + ), + ]; + return { rows, overrides }; + } + + /** Run one pnpm command in the profile, streaming its output as install-log chunks. */ + private async runPnpm( + args: readonly string[], + signal?: AbortSignal, + requestId?: PluginInstallRequestId, + ): Promise { + const jobId = randomUUID(); + const argv = ["pnpm", ...args]; + const cwd = this.profile.dir; + const identity = requestId === undefined ? {} : { requestId }; + const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { + execution: "service", + ...(this.profile.packageManager ?? { command: this.pnpmCommand }), + signal: + signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), + outputBytes: this.outputBytes, + activateNewBundles: false, + idleTimeoutMs: this.idleTimeoutMs, + lookupTimeoutMs: this.inspectTimeoutMs, + onOutput: (text, stream) => { + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream, + text, + }); + }, + }); + this.packageOperations.add(task); + try { + const result = await task; + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream: "stdout", + text: "", + exitCode: signal?.aborted === true ? null : result.exitCode, + }); + // A terminated run keeps its own kind even when pnpm trapped the signal and exited 0. + if (result.exitCode === 0 && result.timedOut !== true) return result; + return { + ...result, + kind: classifyInstallFailure({ + log: result.output, + ...(result.timedOut === true ? { timedOut: true } : {}), + }), + }; + } catch (error) { + this.ownerContext.emit("plugin-manager/install-log", { + ...identity, + jobId, + argv, + cwd, + stream: "stderr", + text: messageOf(error), + exitCode: null, + }); + throw error; + } finally { + this.packageOperations.delete(task); + } + } + + /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */ + private async readRestoredFiles(): Promise> { + const files = new Map(); + for (const name of RESTORED_FILES) { + const path = join(this.profile.dir, name); + files.set(path, existsSync(path) ? await readFile(path, "utf8") : undefined); + } + return files; + } + + /** Put the profile files back; pnpm has exited by the time this runs. */ + private async restoreFiles(files: Map): Promise { + for (const [path, content] of files) { + if (content === undefined) await rm(path, { force: true }); + else await writeFileAtomic(path, content, { mode: 0o600 }); + } + } + + private async selectBundle(name: string, enabled: boolean): Promise { + const manifest = readProfileManifest("dsh", this.profile.dir); + const previous = manifest.dsh?.profile?.bundles ?? []; + if (enabled || !previous.includes(name)) { + const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (metadata === undefined) throw new ManagementFailure("not-bundle"); + if (enabled) { + const compatibility = evaluatePluginCompatibility( + metadata, + readProfileVersionExemptions(this.profile.dir), + ); + if (compatibility !== undefined && !compatibility.exempted) + throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]); + this.bundleRows(name); + } + } + if (!enabled && previous.includes(name)) { + if (this.protectsManager(name)) throw new ManagementFailure("management-required"); + } + const bundles = enabled + ? [...previous, ...(previous.includes(name) ? [] : [name])] + : previous.filter((item) => item !== name); + if (JSON.stringify(previous) === JSON.stringify(bundles)) return; + manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }; + await saveManifest(this.profile.dir, manifest); + if (enabled) this.protectsManager(name); + } + + private bundleRows(name: string): EntryOptions[] { + const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor); + if (info?.dsh?.bundle === undefined) return []; + const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir); + return flatten( + composeEntries([ + bundlePatchPaths(dir, info.dsh.bundle).flatMap((file) => loadOverlayPatches("dsh", file)), + ]), + ); + } + + private protectsManager(name: string): boolean { + if (this.managementBundles.has(name)) return true; + let rows: EntryOptions[]; + try { + rows = this.bundleRows(name); + } catch (_error) { + // Unreadable bundles contribute no new rows; listBundles reports their diagnostics. + return false; + } + const protectedBundle = rows.some( + (row) => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId, + ); + if (protectedBundle) this.managementBundles.add(name); + return protectedBundle; + } + + private configure(operation: () => Promise): Promise { + const hmr = this.ownerContext.get("hmr"); + const apply = () => { + this.abort.signal.throwIfAborted(); + return operation(); + }; + return hmr === undefined ? apply() : hmr.runExclusive(apply); + } + + private async refreshPackages(): Promise { + if (this.ownerContext.get("hmr") === undefined) { + const selected = readProfileManifest("dsh", this.profile.dir).dsh?.profile?.bundles ?? []; + // Deselected startup bundles still run without HMR and need the existing package table. + if (this.profile.startedBundles.some((name) => !selected.includes(name))) return; + } + await this.ownerContext.get("pluginPackages")?.refresh(); + } + + private async reload(requiredIds: readonly string[] = []): Promise { + if (this.ownerContext.get("hmr") === undefined) return []; + return reconcileProfilePatches( + this.ownerContext.root, + readProfilePatches("dsh", this.profile), + "dsh", + requiredIds, + ); + } + + private async change( + operation: (result: ChangeResult) => Promise, + request: Pick, + reason: PluginChange["reason"], + ): Promise { + return withFileLock( + join(this.profile.dir, "package.json"), + async () => { + this.abort.signal.throwIfAborted(); + const before = this.diskState(); + const result: ChangeResult = { + ...request, + changed: false, + application: this.ownerContext.get("hmr") !== undefined ? "applied" : "restart-required", + }; + try { + result.application = (await operation(result)) ?? result.application; + } catch (error) { + if (error instanceof InstallCancelledError) { + result.application = "cancelled"; + } else { + result.application = "failed"; + result.error = managementError(error); + } + } + result.changed = before !== this.diskState(); + this.ownerContext.emit("plugin-manager/changed", { reason }); + return result; + }, + { waitMs: this.lockWaitMs }, + ); + } + + private diskState(): string { + return [ + "package.json", + "cordis.patch.yml", + "pnpm-workspace.yaml", + PROFILE_COMPATIBILITY_FILENAME, + ] + .map((file) => { + try { + return readFileSync(join(this.profile.dir, file), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } + }) + .join("\0"); + } +} + +export default PluginManager; diff --git a/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts b/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts new file mode 100644 index 000000000..69f40009c --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/install-failure.ts @@ -0,0 +1,47 @@ +/** + * What a failed pnpm run was, read off how it ended and what it printed: + * pnpm names its failures with stable `ERR_PNPM_*` codes and Node's errno + * names, which the run's captured tail carries whatever the locale. + * @module @deepseek-ai/dsh-plugin-manager/install-failure + */ + +import type { PluginInstallFailureKind } from "./types.ts"; + +/** How a run ended, beyond its exit code. */ +export interface InstallFailureFacts { + /** The output tail the failure reports. */ + readonly log: string; + /** The spawn error, when the child never ran. */ + readonly cause?: unknown; + /** Whether the run outlived its bound. */ + readonly timedOut?: boolean; +} + +/** Patterns in the order they decide: a specific code before the generic network family. */ +const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [ + ["build-blocked", /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/], + ["not-found", /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/], + ["no-matching-version", /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/], + ["disk-full", /\bENOSPC\b|no space left on device/i], + ["permission", /\bEACCES\b|\bEPERM\b|permission denied/i], + ["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/], + ["unknown", /The requested URL returned error: 40[134]\b/], + [ + "network", + /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/, + ], +]; + +/** + * Classify a failed run. + * @param facts - how the run ended and what it printed. + * @returns the kind, `unknown` when nothing in the facts names one. + */ +export function classifyInstallFailure(facts: InstallFailureFacts): PluginInstallFailureKind { + if (facts.timedOut === true) return "timeout"; + if ((facts.cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return "pnpm-missing"; + for (const [kind, pattern] of LOG_KINDS) { + if (pattern.test(facts.log)) return kind; + } + return "unknown"; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts b/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts new file mode 100644 index 000000000..a2dadab81 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/install-spec.ts @@ -0,0 +1,148 @@ +/** + * Reading an install spec before pnpm sees it: which of pnpm's spec forms it + * takes, and for a registry name whether it is one the registry can accept. + * @module @deepseek-ai/dsh-plugin-manager/install-spec + */ + +import { homedir } from "node:os"; +import { isAbsolute, resolve } from "node:path"; + +/** + * One spec read into its form and the parts an inspection needs. A git spec + * and a tarball URL carry the `host` pnpm fetches them from, which no registry + * stands in for; only their dependencies come from the registry. + */ +export type ParsedInstallSpec = + | { + readonly kind: "registry"; + readonly spec: string; + readonly name: string; + readonly range?: string; + } + | { readonly kind: "path"; readonly spec: string; readonly path: string } + | { + readonly kind: "tarball"; + readonly spec: string; + readonly path?: string; + readonly host?: string; + } + | { readonly kind: "git"; readonly spec: string; readonly host: string }; + +/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */ +const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i; +const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i; +const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i; +/** The hosts pnpm's shorthands stand for. */ +const GIT_SHORTHAND_HOSTS: Readonly> = { + github: "github.com", + gitlab: "gitlab.com", + bitbucket: "bitbucket.org", + gist: "gist.github.com", +}; +/** A tarball, on disk or over HTTP. */ +const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i; +/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */ +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/; +const PACKAGE_NAME_MAX_LENGTH = 214; + +/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */ +export class InvalidInstallSpecError extends Error { + /** + * @param spec - the spec as typed, trimmed. + * @param reason - why it is refused, as one sentence. + */ + constructor( + readonly spec: string, + readonly reason: string, + ) { + super(`plugin-manager: ${reason}: ${spec}`); + this.name = "InvalidInstallSpecError"; + } +} + +function invalid(spec: string, reason: string): InvalidInstallSpecError { + return new InvalidInstallSpecError(spec, reason); +} + +/** The host a git spec is cloned from: the shorthand's host, the scp-like user@host, or the URL's host with its port. */ +function gitHost(spec: string): string { + const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase(); + if (shorthand !== undefined && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) + return GIT_SHORTHAND_HOSTS[shorthand] as string; + const scp = /^git@([^:]+):/i.exec(spec)?.[1]; + if (scp !== undefined) return scp.toLowerCase(); + return new URL(spec.replace(/^git\+/i, "")).host; +} + +/** + * Read a spec into its form. A path must be absolute: the Host's working + * directory means nothing to the person typing into a browser, and a + * relative path resolved against the profile would point inside it. + * @param raw - the spec as typed. + * @returns the parsed spec. + * @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the + * registry would refuse, or a URL that is neither a git host nor a tarball. + */ +export function parseInstallSpec(raw: string): ParsedInstallSpec { + const spec = raw.trim(); + if (spec === "") throw invalid(spec, "the package spec must not be empty"); + const path = spec.replace(/^(?:file|link):/, ""); + if (path !== spec || isAbsolute(path)) { + if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute"); + return TARBALL_SPEC.test(path) ? { kind: "tarball", spec, path } : { kind: "path", spec, path }; + } + if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute"); + const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec); + if (git && !TARBALL_SPEC.test(spec)) return { kind: "git", spec, host: gitHost(spec) }; + if (/^https?:\/\//i.test(spec)) { + if (TARBALL_SPEC.test(spec)) return { kind: "tarball", spec, host: new URL(spec).host }; + throw invalid(spec, "a URL must point at a git repository or a tarball"); + } + const at = spec.indexOf("@", 1); + const name = at === -1 ? spec : spec.slice(0, at); + const range = at === -1 ? undefined : spec.slice(at + 1); + if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) { + throw invalid(spec, "not a package name the registry accepts"); + } + if (range === "") throw invalid(spec, "a version after @ must not be empty"); + return range === undefined + ? { kind: "registry", spec, name } + : { kind: "registry", spec, name, range }; +} + +/** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ +const NON_REGISTRY_VALUE = + /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i; +/** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ +const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i; + +/** + * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other + * non-registry protocol is the recorded value without the user information of an http(s) URL, whose query string + * is kept; a `file:` or `link:` path, which pnpm records relative to the profile or with `~` for the home + * directory, becomes absolute. A registry range, tag, or alias follows the dependency name after `@`, as does a + * non-registry value installed under a name other than the package's own. + * @param name - the dependency name. + * @param recorded - the value the profile manifest records for it. + * @param profileDir - the profile directory a relative local path is resolved against. + * @param packageName - the name the installed package's manifest declares, when it could be read. + * @returns the installable spec. + */ +export function dependencySpec( + name: string, + recorded: string, + profileDir: string, + packageName = name, +): string { + const local = /^(file|link):(.*)$/s.exec(recorded); + let spec: string; + if (local !== null) { + const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir()); + spec = `${local[1]}:${resolve(profileDir, path)}`; + } else if (NON_REGISTRY_VALUE.test(recorded)) { + spec = recorded.replace(HTTP_USER_INFO, "$1"); + } else { + return `${name}@${recorded}`; + } + return packageName === name ? spec : `${name}@${spec}`; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/operations.ts b/apps/web-ui/packages/boot/plugin-manager/src/operations.ts new file mode 100644 index 000000000..0bdd52af5 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/operations.ts @@ -0,0 +1,832 @@ +/** Shared profile package operations used by dsh plugin and the running manager. */ +import { once } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, mkdtemp, open, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { execa } from "execa"; +import type { EntryOptions } from "@deepseek-ai/cordis-plugin-loader"; +import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; +import { + DEFAULT_PROFILE_BUNDLES, + bundlePatchPaths, + initProfile, + PROFILE_TEMPLATES, + readProfileManifest, + resolveBundleDir, + resolveProfileDir, + loadOverlayPatches, + composeEntries, + readProfileVersionExemptions, + evaluatePluginCompatibility, + pluginCompatibilityWarning, + type ProfileManifest, +} from "@deepseek-ai/dsh-app-boot"; +import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess"; +import { parseInstallSpec } from "./install-spec.ts"; +import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from "./run-tree.ts"; +import { incompatiblePlugin } from "./failure.ts"; +import type { IncompatiblePlugin, PackageResult, Registry } from "./types.ts"; +export { + setProfileVersionExemption, + readProfileVersionExemptions, +} from "@deepseek-ai/dsh-app-boot"; + +/** Profile and invocation locations supplied by the launcher. */ +export interface PackageOperationContext { + profile: string; + /** Explicit directory for an application-owned profile; named CLI profiles resolve under home. */ + dir?: string; + installAnchor: string; + cwd: string; + home?: string; +} + +/** Output and cancellation policy for one pnpm operation. */ +export interface PackageOperationOptions { + /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */ + command?: string; + /** Prefix arguments for an application-owned executable. */ + args?: readonly string[]; + /** Application runtime environment, applied only to this package operation. */ + env?: Readonly>; + /** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */ + execution: "cli" | "service"; + signal?: AbortSignal; + outputBytes: number; + onOutput?: (text: string, stream: "stdout" | "stderr") => void; + activateNewBundles?: boolean; + lockWaitMs?: number; + /** + * Terminate the run once its captured output has been silent for this long, in + * milliseconds. A run stopped this way reports `timedOut`; without a bound, a + * child that stops progressing without exiting holds its caller forever. A run + * with inherited descriptors captures nothing and is never bound. + */ + idleTimeoutMs?: number; + /** Bound on the pre-install registry lookup, in milliseconds; without one a fixed bound applies. */ + lookupTimeoutMs?: number; +} + +/** Resolve relative package specs against the caller's directory. + * @param argument One pnpm argument. + * @param cwd Invocation directory, never the profile directory. + * @returns Anchored argument. + */ +export function anchorPathSpec(argument: string, cwd: string): string { + const match = /^(?(?:file|link):)?(?\.{1,2}(?:[/\\].*)?)$/.exec(argument); + if (match?.groups?.path === undefined) return argument; + return `${match.groups.prefix ?? ""}${resolve(cwd, match.groups.path)}`; +} + +/** Read bundle metadata without loading its JavaScript. + * @param name Installed dependency or installation-owned package name. + * @param dir Profile directory. + * @param anchor Installation manifest. + * @returns Resolved metadata, or undefined for packages without bundle metadata. + */ +export function bundleManifest( + name: string, + dir: string, + anchor: string, +): ProfileManifest | undefined { + const packageDir = resolveBundleDir("dsh", name, anchor, dir); + const manifest = readProfileManifest("dsh", packageDir); + return manifest.dsh?.bundle?.patch === undefined ? undefined : manifest; +} + +/** Atomically save a profile manifest while retaining unrelated fields. + * @param dir Profile directory. + * @param manifest Updated document. + */ +export async function saveManifest(dir: string, manifest: ProfileManifest): Promise { + await writeFileAtomic(join(dir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n", { + mode: 0o600, + }); +} + +/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */ +async function reconcile( + before: ProfileManifest, + dir: string, + anchor: string, + options: PackageOperationOptions, +): Promise { + const after = readProfileManifest("dsh", dir); + const dependencies = Object.keys(after.dependencies ?? {}); + const beforeDeps = new Set(Object.keys(before.dependencies ?? {})); + const previous = after.dsh?.profile?.bundles ?? []; + const bundles = previous.filter((name) => { + if (!beforeDeps.has(name) && !dependencies.includes(name)) return true; + return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined; + }); + for (const name of dependencies) { + if (beforeDeps.has(name)) continue; + const metadata = bundleManifest(name, dir, anchor); + if (metadata?.dsh?.bundle === undefined) { + options.onOutput?.( + `dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, + "stderr", + ); + continue; + } + for (const file of bundlePatchPaths( + resolveBundleDir("dsh", name, anchor, dir), + metadata.dsh.bundle, + )) + loadOverlayPatches("dsh", file); + if (!bundles.includes(name)) { + bundles.push(name); + } + } + if (JSON.stringify(previous) === JSON.stringify(bundles)) return; + after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } }; + await saveManifest(dir, after); +} + +/** + * How long the pipes keep draining after their process exited, as a fixed part of + * finishing a run rather than a deployment knob: a descendant that inherited them + * holds them open, and the tail a failure classification reads is written by then. + */ +const DRAIN_AFTER_EXIT_MS = 2_000; + +/** Whether every collector finished within `ms`. + * @param collectors The pipe readers racing the bound. + * @param ms The longest wait, in milliseconds. + * @returns True when all collectors settled in time. + */ +async function drainWithin(collectors: readonly Promise[], ms: number): Promise { + if (collectors.length === 0) return true; + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.allSettled(collectors).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => { + resolve(false); + }, ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** Install commands that take the packages to install as positionals. */ +const INSTALL_COMMANDS = new Set(["add", "install", "i"]); + +/** Bound on a pre-install registry lookup when the caller names none. */ +const LOOKUP_TIMEOUT_MS = 20_000; + +/** Package specs an install command names explicitly, in order. */ +function namedSpecs(args: readonly string[]): string[] { + const index = args.findIndex((argument) => !argument.startsWith("-")); + const command = index < 0 ? undefined : args[index]; + if (command === undefined || !INSTALL_COMMANDS.has(command)) return []; + return args.slice(index + 1).filter((argument) => !argument.startsWith("-")); +} + +/** The manifest a named spec would install, read without installing it. + * A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the + * range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the + * check after installation is what judges it. + * @param dir Profile directory the lookup runs in. + * @param spec Anchored install spec. + * @param options Pnpm executable, prefix arguments, the caller's bound and signal. + * @param environment Environment of the caller's pnpm invocations. + * @param flags Flags of the run itself, so the lookup asks the registry that run will use. + * @returns The package manifest, or undefined when reading it would need the installation itself. + */ +async function namedSpecManifest( + dir: string, + spec: string, + options: PackageOperationOptions, + environment: Readonly>, + flags: readonly string[], +): Promise { + const parsed = parseInstallSpec(spec); + if (parsed.kind === "path") { + const filename = join(parsed.path, "package.json"); + return existsSync(filename) + ? (JSON.parse(readFileSync(filename, "utf8")) as object) + : undefined; + } + if (parsed.kind !== "registry") return undefined; + const viewed = await execa( + options.command ?? "pnpm", + [ + ...(options.args ?? []), + "view", + parsed.spec, + "name", + "version", + "peerDependencies", + "--json", + ...flags, + "--config.fetch-retries=0", + ], + { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdin: "ignore", + ...(options.signal === undefined ? {} : { cancelSignal: options.signal }), + timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS, + }, + ); + if (viewed.exitCode !== 0) return undefined; + const value: unknown = JSON.parse(viewed.stdout); + return (Array.isArray(value) ? value.at(-1) : value) as object; +} + +/** Missing installed packages are repairable; their absence is part of the before/after comparison. */ +function optionalFile(path: string): string | undefined { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */ +function runRecordPath(dir: string): string { + return join(dir, ".plugin-manager", "run.json"); +} + +/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */ +async function recordRun(dir: string, tree: RunTree): Promise { + if (tree.pid === undefined) return; + await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { + mode: 0o600, + dirMode: 0o700, + }); +} + +/** + * Why this operation must not run: a record left by an operation whose process + * ended mid-run names a run that is still writing the profile. The profile + * lock is taken over once its holder exits, but its pnpm tree can outlive it. + * A recorded run that stopped, within a bounded wait, has its record removed. + * @param dir Profile directory. + * @returns The diagnostic, or undefined when no recorded run is active. + */ +async function activeRecordedRun(dir: string): Promise { + const path = runRecordPath(dir); + const text = optionalFile(path); + if (text === undefined) return undefined; + let tree: RunTree | undefined; + try { + const value: unknown = JSON.parse(text); + const { pid, grouped } = (typeof value === "object" && value !== null ? value : {}) as { + pid?: unknown; + grouped?: unknown; + }; + if (Number.isSafeInteger(pid) && (pid as number) > 0 && typeof grouped === "boolean") + tree = { pid: pid as number, grouped }; + } catch (error) { + // Records are replaced atomically, so an unparsable one was written by something else and is reported below. + void error; + } + if (tree === undefined) { + return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`; + } + await awaitTreeGone(tree); + if (treeAlive(tree)) { + return ( + `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; ` + + `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n` + ); + } + await rm(path, { force: true }); + return undefined; +} + +/** pnpm can install plugins through any direct-dependency field. */ +function directDependencies(manifest: ProfileManifest): Record { + const extra = manifest as ProfileManifest & { + devDependencies?: Record; + optionalDependencies?: Record; + }; + return { ...extra.devDependencies, ...manifest.dependencies, ...extra.optionalDependencies }; +} + +/** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */ +function bundleComponentManifests( + manifest: ProfileManifest, + dir: string, + anchor: string, +): ProfileManifest[] { + const bundle = manifest.dsh?.bundle; + if (bundle === undefined) return []; + const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file)); + const names = new Set(); + const visit = (rows: EntryOptions[]) => { + for (const row of rows) { + if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[]); + if ( + typeof row.name !== "string" || + row.name.startsWith(".") || + row.name.startsWith("/") || + row.name.includes(":") + ) + continue; + const parts = row.name.split("/"); + names.add(parts.slice(0, row.name.startsWith("@") ? 2 : 1).join("/")); + } + }; + visit(composeEntries([patches.filter((patch) => patch.insert !== undefined)])); + return [...names].flatMap((name) => { + let packageDir: string; + try { + packageDir = resolveBundleDir("dsh", name, anchor, dir); + } catch (error) { + // Resolution errors for uninstalled or dynamic rows remain subject to the startup loader's checks. + void error; + return []; + } + return [readProfileManifest("dsh", packageDir)]; + }); +} + +/** Execute pnpm inside a profile whose caller already holds the profile write lock. + * Newly installed or updated direct dependencies are checked even when activation is disabled; + * an untouched dependency never blocks an unrelated operation and stays denied at startup. + * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk. + * @param context Launcher-owned profile and resolution locations. + * @param args Pnpm arguments, before relative path anchoring. + * @param options Output, activation and cancellation policy. + * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. + * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors. + */ +export async function runProfilePnpm( + context: PackageOperationContext, + args: readonly string[], + options: PackageOperationOptions, +): Promise { + const dir = context.dir ?? resolveProfileDir(context.profile, context.home); + // Before any profile file is read: a recorded run that is still active may be rewriting them. + const active = await activeRecordedRun(dir); + const logRoot = join(dir, ".plugin-manager", "logs"); + await mkdir(logRoot, { recursive: true, mode: 0o700 }); + const logDir = await mkdtemp(join(logRoot, "operation-")); + const logPath = join(logDir, "pnpm.log"); + const log = await open(logPath, "wx", 0o600); + if (active !== undefined) { + await log.write(active); + await log.close(); + options.onOutput?.(active, "stderr"); + const bytes = Buffer.from(active); + return { + exitCode: 1, + output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString("utf8"), + truncated: bytes.length > options.outputBytes, + logPath, + }; + } + const before = readProfileManifest("dsh", dir); + const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({ + path: join(dir, name), + text: optionalFile(join(dir, name)), + })); + const beforeDependencies = directDependencies(before); + const installedBefore = new Map( + Object.keys(beforeDependencies).map((name) => [ + name, + optionalFile(join(dir, "node_modules", name, "package.json")), + ]), + ); + let output = Buffer.alloc(0); + let truncated = false; + const append = (bytes: Buffer): void => { + output = Buffer.concat([output, bytes]); + if (output.length > options.outputBytes) { + truncated = true; + output = output.subarray(output.length - options.outputBytes); + } + }; + const environment = { + ...(options.execution === "cli" ? process.env : scrubbedParentEnv()), + ...options.env, + }; + const restore = async (): Promise => { + for (const file of savedFiles) { + if (file.text === undefined) await rm(file.path, { force: true }); + else await writeFileAtomic(file.path, file.text, { mode: 0o600 }); + } + }; + /** Packages a compatibility check refused; callers render them for their own surface. */ + const incompatible: IncompatiblePlugin[] = []; + const rejected = async ( + warnings: readonly string[], + restoration: string, + ): Promise => { + const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`; + await log.write(diagnostic); + options.onOutput?.(diagnostic, "stderr"); + append(Buffer.from(diagnostic)); + await log.close(); + return { exitCode: 1, output: output.toString("utf8"), truncated, logPath, incompatible }; + }; + // An install command names the packages it adds, so their manifests are read and checked before + // pnpm runs: an incompatible version is never installed, and the one already in use keeps working. + const preflight: string[] = []; + const exemptions = readProfileVersionExemptions(dir); + // The run's own registry flags, so the lookup asks the registry the installation will use. + const registryFlags = args.filter((argument) => argument.startsWith("--registry=")); + for (const raw of namedSpecs(args)) { + // A spec whose manifest cannot be read or validated is left to the run itself and to the check + // after installation, which reports what it could not validate. + try { + const manifest = await namedSpecManifest( + dir, + anchorPathSpec(raw, context.cwd), + options, + environment, + registryFlags, + ); + if (manifest === undefined) continue; + const issue = evaluatePluginCompatibility(manifest, exemptions); + if (issue !== undefined && !issue.exempted) { + preflight.push(pluginCompatibilityWarning(issue)); + incompatible.push(incompatiblePlugin(issue)); + } + } catch (error) { + void error; + continue; + } + } + if (preflight.length > 0) return rejected(preflight, "nothing was installed"); + const cancellation = new AbortController(); + // A service run captures output, so execa terminates the tree it leads when the + // run is killed: a lifecycle script outlives the pnpm process that started it. + // The CLI keeps the caller's process group, so an interrupt still reaches it. + const grouped = leadsOwnGroup(options.execution); + const child = execa( + options.command ?? "pnpm", + [...(options.args ?? []), ...args.map((arg) => anchorPathSpec(arg, context.cwd))], + { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdout: options.execution === "cli" ? "inherit" : "pipe", + stderr: options.execution === "cli" ? "inherit" : "pipe", + killDescendants: options.execution === "service", + buffer: false, + stdin: options.execution === "cli" ? "inherit" : "ignore", + cancelSignal: + options.signal === undefined + ? cancellation.signal + : AbortSignal.any([cancellation.signal, options.signal]), + }, + ); + // Listened for before the record is written, so an exit during that write is not missed. + const exited = once(child.nodeChildProcess, "exit").catch(() => undefined); + let writes = Promise.resolve(); + /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */ + const control = { settled: false, stalled: false }; + /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */ + let cut = false; + /** The first failure a reading hit before that cut, which the run still reports. */ + let failure: Error | undefined; + let idleTimer: NodeJS.Timeout | undefined; + /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */ + const armIdle = (): void => { + if (control.settled || options.idleTimeoutMs === undefined) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + control.stalled = true; + // execa's kill reaches the whole tree of a service run, and escalates on its own. + child.kill(); + }, options.idleTimeoutMs); + }; + const collect = async (stream: AsyncIterable, kind: "stdout" | "stderr") => { + try { + for await (const chunk of stream) { + armIdle(); + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + writes = writes.then(async () => { + await log.write(bytes); + }); + await writes; + options.onOutput?.(bytes.toString("utf8"), kind); + append(bytes); + } + } catch (error) { + // A reading this call cut short is not a failure the run hit, and the run has + // already exited, so there is nothing left for the cancellation to stop. + if (!cut) { + failure ??= error instanceof Error ? error : new Error(String(error)); + cancellation.abort(); + } + throw error; + } + }; + const collectors = [ + ...(child.stdout === null ? [] : [collect(child.stdout, "stdout")]), + ...(child.stderr === null ? [] : [collect(child.stderr, "stderr")]), + ]; + // The drain decides whether a failure surfaces, so each reading is claimed now: + // an unclaimed rejection would be reported as unhandled while the pipes drain. + for (const collector of collectors) + void collector.catch(() => { + /* reported through `failure` and the settled readings */ + }); + // Inherited descriptors hand pnpm the terminal, so there is no captured output + // for a silence bound to observe: it would fire on a healthy run. + if (collectors.length > 0) armIdle(); + let exitCode: number; + try { + await recordRun(dir, { pid: child.pid, grouped }); + // execa resolves its promise only once the piped stdio has ended, so the + // process's own exit — the run's completion — is read from the raw child. A + // spawn failure settles without one. + const settled = Promise.allSettled([child]); + await Promise.race([exited, settled]); + control.settled = true; + clearTimeout(idleTimer); + // A stalled run stops its whole tree first, so the caller's rollback and lock + // release happen only after the scripts it started stopped writing. + if (control.stalled) await awaitTreeGone({ pid: child.pid, grouped }); + // A descendant that inherited the pipes can hold them open past the process; + // the tail drains under a bound instead of being awaited forever. + const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS); + if (drained) { + for (const stream of await Promise.allSettled(collectors)) + if (stream.status === "rejected") throw stream.reason; + } else { + // Cutting the tail short is this call's own end, not a failure the run hit; + // a failure from before the cut still surfaces, and the cut leaves a notice + // in the log because a classification may read an incomplete tail. + cut = true; + child.stdout?.destroy(); + child.stderr?.destroy(); + const notice = "dsh: pnpm output was cut short after its process exited\n"; + await log.write(notice); + // A failure from before the cut is the run's own and replaces the notice a + // caller would otherwise read; a rejection the cut itself causes is its end. + if (failure !== undefined) throw failure; + options.onOutput?.(notice, "stderr"); + append(Buffer.from(notice)); + } + const [completion] = await settled; + if (completion.status === "rejected") throw completion.reason; + const result = completion.value; + exitCode = result.exitCode ?? (result.code === "ENOENT" ? 127 : 1); + if (control.stalled) { + const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`; + await log.write(notice); + options.onOutput?.(notice, "stderr"); + append(Buffer.from(notice)); + } + if (result.failed && output.length === 0) { + const diagnostic = result.shortMessage ?? "pnpm failed"; + await log.write(diagnostic); + truncated = Buffer.byteLength(diagnostic) > options.outputBytes; + output = Buffer.from(diagnostic).subarray(0, options.outputBytes); + } + // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection. + if (exitCode === 0 && !control.stalled) { + const after = readProfileManifest("dsh", dir); + const warnings: string[] = []; + for (const [name, spec] of Object.entries(directDependencies(after))) { + const packageDir = join(dir, "node_modules", name); + const installed = optionalFile(join(packageDir, "package.json")); + if (installed === undefined) continue; + // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it. + const untouched = + beforeDependencies[name] === spec && installedBefore.get(name) === installed; + const found: string[] = []; + const issues: IncompatiblePlugin[] = []; + try { + const manifest = readProfileManifest("dsh", packageDir); + for (const candidate of [ + manifest, + ...bundleComponentManifests(manifest, packageDir, context.installAnchor), + ]) { + const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir)); + if (issue !== undefined && !issue.exempted) { + found.push(pluginCompatibilityWarning(issue)); + issues.push(incompatiblePlugin(issue)); + } + } + } catch (error) { + found.push(`Cannot validate installed package ${name}: ${String(error)}`); + } + if (found.length === 0) continue; + if (!untouched) { + warnings.push(...found); + incompatible.push(...issues); + } else { + const notice = `\ndsh: warning: ${found.join("\n")}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`; + await log.write(notice); + options.onOutput?.(notice, "stderr"); + } + } + if (warnings.length > 0) { + // A bundle component's peers need installed contents, so this rejection lands after pnpm + // replaced the tree: restore the files, then reinstall the restored lockfile so the version + // that worked before this run keeps loading. A profile that had no lockfile is reinstalled + // from its restored manifest without creating one, which removes what this run added. + await restore(); + const hadLockfile = savedFiles.some( + (file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== undefined, + ); + const repair = ["install", hadLockfile ? "--frozen-lockfile" : "--config.lockfile=false"]; + const repairing = execa(options.command ?? "pnpm", [...(options.args ?? []), ...repair], { + cwd: dir, + env: environment, + extendEnv: false, + reject: false, + stdin: "ignore", + ...(options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs }), + }); + await recordRun(dir, { pid: repairing.pid, grouped: false }); + const repaired = await repairing; + exitCode = 1; + const restoration = + repaired.exitCode === 0 + ? "restored package.json, pnpm-lock.yaml, and node_modules" + : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'"; + const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`; + await log.write(diagnostic); + options.onOutput?.(diagnostic, "stderr"); + append(Buffer.from(diagnostic)); + } else if (options.activateNewBundles !== false) { + await reconcile(before, dir, context.installAnchor, options); + } + } + } finally { + control.settled = true; + clearTimeout(idleTimer); + // This process saw the run end, so no successor has to wait for it. + await rm(runRecordPath(dir), { force: true }); + await log.close(); + } + return { + exitCode, + output: output.toString("utf8"), + truncated, + logPath, + ...(control.stalled ? { timedOut: true } : {}), + ...(incompatible.length > 0 ? { incompatible } : {}), + }; +} + +/** Initialize and run the dsh plugin command with the same write lock as the service. + * @param context Launcher-owned locations. + * @param args Pnpm arguments. + * @param options Output and cancellation policy. + * @returns Completed package-manager result. + */ +export async function runPluginCommand( + context: PackageOperationContext, + args: readonly string[], + options: PackageOperationOptions, +): Promise { + const dir = context.dir ?? resolveProfileDir(context.profile, context.home); + await mkdir(dir, { recursive: true }); + return withFileLock( + join(dir, "package.json"), + async () => { + if (!existsSync(join(dir, "package.json"))) { + const template = PROFILE_TEMPLATES[context.profile]; + initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES); + options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, "stderr"); + } + return runProfilePnpm(context, args, options); + }, + options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs }, + ); +} + +/** What one registry lookup answered. */ +export interface PackageViewResult { + /** pnpm's exit code, null when it ended without one or never started. */ + exitCode: number | null; + stdout: string; + stderr: string; + /** The lookup ran past its bound and was killed. */ + timedOut: boolean; + /** The failure of starting pnpm at all, when that is what happened. */ + cause?: unknown; +} + +/** Bounds of one registry lookup. */ +export interface PackageViewOptions { + /** The pnpm executable name or path. Defaults to `pnpm`. */ + command?: string; + /** Prefix arguments for an application-owned executable. */ + args?: readonly string[]; + /** Application runtime environment, applied only to this package operation. */ + env?: Readonly>; + /** Ends the lookup early; the caller's signal, when it has one. */ + signal?: AbortSignal; + /** Bound on the lookup, in milliseconds. */ + timeoutMs: number; + /** The registry asked; null asks the one pnpm's own configuration names. */ + registry?: Registry; +} + +/** + * Read the registry pnpm's own configuration names in the profile: its `.npmrc` chain and workspace settings, + * as `pnpm config get registry` resolves them. + * @param dir Profile directory. + * @param options The pnpm executable and the time bound. + * @returns The registry URL as pnpm printed it, or null when pnpm did not answer with one. + */ +export async function readProfileRegistry( + dir: string, + options: { + command?: string; + args?: readonly string[]; + env?: Readonly>; + timeoutMs: number; + }, +): Promise { + const result = await execa( + options.command ?? "pnpm", + [...(options.args ?? []), "config", "get", "registry"], + { + cwd: dir, + env: { ...scrubbedParentEnv(), ...options.env }, + extendEnv: false, + reject: false, + stdin: "ignore", + timeout: options.timeoutMs, + }, + ); + // The registry is the last line: pnpm may print a notice before it. + const answer = + result.exitCode === 0 + ? result.stdout + .trim() + .replace(/^[\s\S]*\n/, "") + .trim() + : ""; + return /^https?:\/\/\S+$/.test(answer) ? answer : null; +} + +/** + * The argument that sends one pnpm command to a registry. + * @param registry - the registry, or null for the one pnpm's own configuration names. + * @returns `--registry=` for a URL; nothing for null. + */ +export function registryArguments(registry: Registry): string[] { + return registry === null ? [] : [`--registry=${registry}`]; +} + +/** + * Ask the registry what a spec names through `pnpm view`, run in the profile + * directory so the registry, proxy, and authentication settings of an install + * apply. The lookup makes one request without pnpm's own retries: a registry + * that does not answer is reported within `timeoutMs`, and the registries + * configured after it are the retry. + * @param dir Profile directory. + * @param spec One registry spec: a package name with an optional range. + * @param options The registry, cancellation, and the time bound. + * @returns pnpm's exit, output, and how the lookup ended. + */ +export async function viewProfilePackage( + dir: string, + spec: string, + options: PackageViewOptions, +): Promise { + const result = await execa( + options.command ?? "pnpm", + [ + ...(options.args ?? []), + "view", + spec, + "name", + "version", + "description", + "dsh", + "--json", + ...registryArguments(options.registry ?? null), + "--config.fetch-retries=0", + ], + { + cwd: dir, + env: { ...scrubbedParentEnv(), ...options.env }, + extendEnv: false, + reject: false, + stdin: "ignore", + timeout: options.timeoutMs, + ...(options.signal === undefined ? {} : { cancelSignal: options.signal }), + }, + ); + const cause = + result.exitCode === undefined && !result.timedOut && !result.isCanceled + ? Object.assign(new Error(result.shortMessage), { code: result.code }) + : undefined; + return { + exitCode: result.exitCode ?? null, + stdout: result.stdout, + stderr: result.stderr, + timedOut: result.timedOut, + ...(cause === undefined ? {} : { cause }), + }; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/patch.ts b/apps/web-ui/packages/boot/plugin-manager/src/patch.ts new file mode 100644 index 000000000..37d3d04f5 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/patch.ts @@ -0,0 +1,48 @@ +/** Comment-preserving profile plugin enablement edits. */ +import { readFile } from "node:fs/promises"; +import { isMap, isSeq, parseDocument } from "yaml"; +import { loadOptionalPatches } from "@deepseek-ai/dsh-app-boot"; +import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write"; + +/** Replace the last matching override or append one after existing insertions. + * @param filename Current profile patch file. + * @param id Unique composition entry id. + * @param name Module name used to match name-qualified overrides. + * @param enabled Desired entry enablement. + * @returns Whether the file changed. + */ +export async function writePluginEnabled( + filename: string, + id: string, + name: string, + enabled: boolean, +): Promise { + let text: string; + try { + text = await readFile(filename, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + text = "[]\n"; + } + const document = parseDocument(text, { + customTags: [{ tag: "tag:yaml.org,2002:js", resolve: (value: string) => value }], + }); + const error = document.errors[0]; + if (error !== undefined) throw error; + if (!isSeq(document.contents)) throw new Error("Profile patch must be a YAML sequence"); + loadOptionalPatches("dsh", filename); + const items = document.contents.items; + const target = items.findLast((item, index) => { + if (!isMap(item) || document.getIn([index, "id"]) !== id || item.has("insert")) return false; + const expectedName = document.getIn([index, "name"]); + return !expectedName || expectedName === name; + }); + if (isMap(target)) { + if (document.getIn([items.indexOf(target), "disabled"]) === !enabled) return false; + document.setIn([items.indexOf(target), "disabled"], !enabled); + } else { + document.add({ id, disabled: !enabled }); + } + await writeFileAtomic(filename, String(document), { mode: 0o600 }); + return true; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/registry.ts b/apps/web-ui/packages/boot/plugin-manager/src/registry.ts new file mode 100644 index 000000000..6bea8a723 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/registry.ts @@ -0,0 +1,118 @@ +/** + * Which registries one package operation asks, in order, and what a failed + * attempt could not reach. A registry is asked with pnpm's `--registry`; + * `null` is the one pnpm's own configuration names. This module runs in the + * browser too, through the package's `./registry` entry, so the dialog and + * the Host follow one rule. + * @module @deepseek-ai/dsh-plugin-manager/registry + */ + +import type { ParsedInstallSpec } from "./install-spec.ts"; +import type { PluginInstallFailureKind, PluginRegistries, Registry } from "./types.ts"; + +/** npm's own registry: what pnpm names without any configuration, and the one public registry a plan trusts as such. */ +export const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/"; + +/** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */ +export const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/"; + +/** An http(s) URL, as pnpm's `--registry` takes it. */ +export const REGISTRY_URL = /^https?:\/\/\S+$/; + +/** + * Parse a registry URL into the form pnpm compares registries in: lower-case host, trailing slash. + * @param url - the registry as configured or requested. + * @returns the normalized URL. + * @throws {Error} for anything but an http(s) URL. + */ +export function normalizeRegistry(url: string): string { + let parsed: URL | undefined; + try { + parsed = new URL(url); + } catch { + /* named below: only an http(s) URL is a registry */ + } + if (parsed === undefined || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + throw new Error(`a registry must be an http(s) URL: ${url}`); + } + if (!parsed.pathname.endsWith("/")) parsed.pathname += "/"; + return parsed.href; +} + +/** + * The registries one operation asks, first to last. + * + * The configured set is the configured first registry and the fallbacks; a + * requested registry is asked first when it is one of them, alone otherwise, + * so a private registry never falls through to a public one. pnpm's own + * registry (`null`) belongs to the set only when what it names is known to + * be public: npm's own registry or one of the configured fallbacks. While it + * names anything else, or is unknown, it is asked alone, and a public + * registry asked instead never falls back into it. A registry pnpm's own + * configuration already names is asked once. + * @param requested - the caller's registry; undefined defers to the configured first one. + * @param configured - the configured first registry, the fallbacks after it, and what pnpm's own configuration names. + * @returns the registries to ask, in order; never empty. + */ +export function registryPlan( + requested: Registry | undefined, + configured: PluginRegistries, +): Registry[] { + const own = configured.resolved === null ? null : normalizeRegistry(configured.resolved); + const fallbacks = configured.fallbackRegistries.map(normalizeRegistry); + const ownIsPublic = own !== null && (own === OFFICIAL_NPM_REGISTRY || fallbacks.includes(own)); + // What a registry is compared as: pnpm's own registry stands for the URL it names, once that is known. + const keyOf = (registry: Registry): string | null => + registry === null ? own : normalizeRegistry(registry); + const known: Registry[] = []; + const keys: (string | null)[] = []; + for (const registry of [configured.registry, ...configured.fallbackRegistries]) { + if (registry === null && !ownIsPublic) continue; + const key = keyOf(registry); + if (keys.includes(key)) continue; + known.push(registry === null ? null : normalizeRegistry(registry)); + keys.push(key); + } + const first = requested === undefined ? configured.registry : requested; + const firstKey = keyOf(first); + const normalizedFirst = first === null ? null : normalizeRegistry(first); + // A private or unknown registry of pnpm's own, whichever way it was asked for, is asked alone. + if ((first === null || firstKey === own) && !ownIsPublic) return [normalizedFirst]; + if (!keys.includes(firstKey)) return [normalizedFirst]; + return [normalizedFirst, ...known.filter((_registry, index) => keys[index] !== firstKey)]; +} + +/** The failures after which another registry can answer differently: this one was unreachable, or its copy may be stale. */ +const NEXT_REGISTRY_KINDS: ReadonlySet = new Set([ + "network", + "timeout", + "not-found", + "no-matching-version", +]); + +/** A line of pnpm or git output that reports a failure, as opposed to a warning or progress line. */ +const ERROR_LINE = + /ERR_|ERROR|\berror\b|fatal:|Could not resolve|unable to access|ssh:|\bE[A-Z]{4,}\b/; + +/** + * What a failed attempt could not reach or get an answer from. + * @param kind - how the attempt failed. + * @param log - what the attempt printed. + * @param spec - the spec the attempt installed. + * @returns `registry` for a failure another registry can change; `spec-host` when an error line names the host a git + * or tarball spec is fetched from, which no registry stands in for; `other` for a failure neither explains. + */ +export function attributeFailure( + kind: PluginInstallFailureKind, + log: string, + spec: ParsedInstallSpec, +): "registry" | "spec-host" | "other" { + if (!NEXT_REGISTRY_KINDS.has(kind)) return "other"; + const host = + spec.kind === "git" || spec.kind === "tarball" ? spec.host?.toLowerCase() : undefined; + if (host === undefined) return "registry"; + const named = log + .split("\n") + .some((line) => ERROR_LINE.test(line) && line.toLowerCase().includes(host)); + return named ? "spec-host" : "registry"; +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts b/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts new file mode 100644 index 000000000..74bd40aa4 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/run-tree.ts @@ -0,0 +1,88 @@ +/** Waiting for one package run's process tree to disappear before its caller touches the profile. */ + +/** How long a terminated tree may take to disappear before the caller stops waiting. */ +const TREE_WAIT_MS = 5_000; + +/** Poll cadence while waiting for a terminated tree to disappear. */ +const TREE_POLL_MS = 15; + +/** The process (or process group) one run occupies. */ +export interface RunTree { + /** The run's process id, when its spawn published one. */ + pid: number | undefined; + /** Whether the run leads its own process group, which is what a POSIX group probe addresses. */ + grouped: boolean; +} + +/** Injectable process operations, so every platform's probing is testable on any host. */ +export interface RunTreeInternals { + /** Host platform override for target decisions. */ + platform?: NodeJS.Platform; + /** Liveness probe override, defaulting to `process.kill(target, 0)`. */ + alive?: (target: number) => boolean; + /** Wait bound override, defaulting to the tree wait constant. */ + waitMs?: number; +} + +/** + * Whether one run leads its own process group, which is the target a POSIX + * liveness probe addresses for the whole tree. A run that captures output is + * spawned as its own group leader, because its tree is terminated as a unit; a + * run that inherits the caller's descriptors keeps the caller's group, so an + * interrupt still reaches it. + * @param execution Whether the run captures output or inherits the caller's descriptors. + * @param platform Host platform deciding how a tree is addressed. + * @returns True when the run leads its own process group. + */ +export function leadsOwnGroup( + execution: "cli" | "service", + platform: NodeJS.Platform = process.platform, +): boolean { + return execution === "service" && platform !== "win32"; +} + +/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */ +function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): number { + return platform !== "win32" && grouped ? -pid : pid; +} + +/** + * Whether any member of a run's tree is still alive. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns True while the probe finds the process or a group member. + */ +export function treeAlive(tree: RunTree, internals: RunTreeInternals = {}): boolean { + const pid = tree.pid; + if (pid === undefined) return false; + const platform = internals.platform ?? process.platform; + const alive = + internals.alive ?? + ((target: number) => { + process.kill(target, 0); + return true; + }); + try { + return alive(targetOf(pid, tree.grouped, platform)); + } catch { + return false; + } +} + +/** + * Wait until a terminated run's tree is gone, so the caller restores and + * unlocks the profile only after the scripts it started stopped writing. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns Fulfillment once no member remains, or the wait bound elapsed. + */ +export async function awaitTreeGone( + tree: RunTree, + internals: RunTreeInternals = {}, +): Promise { + const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS); + while (treeAlive(tree, internals)) { + if (Date.now() >= deadline) return; + await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS)); + } +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/tools.ts b/apps/web-ui/packages/boot/plugin-manager/src/tools.ts new file mode 100644 index 000000000..f03976ab3 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/tools.ts @@ -0,0 +1,192 @@ +/** Agent-facing current-profile management using the same service as Web controls. */ +import { assertNever } from "@deepseek-ai/dsh-util-values"; +import type { Context } from "@deepseek-ai/cordis"; +import type {} from "./index.ts"; +import type {} from "@deepseek-ai/dsh-sandbox-policy"; +import type {} from "@deepseek-ai/dsh-user-approval"; +import { approveEscalation } from "@deepseek-ai/dsh-sandbox"; +import type { PluginEntryId } from "./types.ts"; +import { defineTool } from "@deepseek-ai/dsh-tools"; +import { getDshRuntimeVersion } from "@deepseek-ai/dsh-app-boot"; + +/** Required services for the management tool. */ +export const inject = ["tools", "pluginManager", "sandboxPolicy"]; + +/** Register one management tool for discovery and the four persistent actions. + * @param ctx Agent-scoped tool registration context. + */ +export function apply(ctx: Context): void { + ctx.tools.register( + defineTool({ + name: "plugin_manager", + description: + "List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.", + parameters: { + action: { + type: "string", + required: true, + enum: [ + "list_plugins", + "list_bundles", + "set_plugin", + "set_bundle", + "install_bundle", + "remove_bundle", + "list_version_exemptions", + "set_version_exemption", + ], + description: "Management operation.", + }, + target: { + type: "string", + description: + "Plugin entry id, bundle package name, or installation spec, according to action.", + }, + enabled: { + type: "boolean", + description: + "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.", + }, + runtimeVersion: { + type: "string", + description: + "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.", + }, + acceptRisk: { + type: "boolean", + description: + "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.", + }, + approvedBuilds: { + type: "array", + items: { type: "string" }, + description: + "For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.", + }, + registry: { + type: "string", + description: + "For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.", + }, + offset: { type: "number", description: "Zero-based list offset; defaults to 0." }, + limit: { type: "number", description: "List page size, from 1 to 100; defaults to 25." }, + }, + output: { + schema: { type: "string" }, + render: (_args, value) => [{ type: "text", text: value }], + }, + async execute(args, exec) { + const policy = ctx.sandboxPolicy.resolve( + exec.agent === undefined ? {} : { session: exec.agent.session }, + ); + await approveEscalation( + { + requestedMode: "danger-full-access", + effectiveMode: policy.mode, + subject: "plugin management operation", + justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`, + }, + { + approver: ctx.get("approval"), + agent: exec.agent, + callId: exec.callId, + toolName: "plugin_manager", + signal: exec.signal, + }, + ); + exec.signal.throwIfAborted(); + const manager = ctx.pluginManager; + switch (args.action) { + case "list_version_exemptions": + return JSON.stringify({ + runtimeVersion: getDshRuntimeVersion(), + ...manager.listVersionExemptions(), + }); + case "set_version_exemption": + if ( + args.target === undefined || + args.runtimeVersion === undefined || + args.enabled === undefined + ) { + throw new Error( + "target package-name@version, runtimeVersion, and enabled are required", + ); + } + return JSON.stringify( + await manager.setVersionExemption( + args.target, + args.runtimeVersion, + args.enabled, + args.acceptRisk, + ), + ); + case "list_plugins": + case "list_bundles": { + const offset = args.offset ?? 0; + const limit = args.limit ?? 25; + if ( + !Number.isInteger(offset) || + offset < 0 || + !Number.isInteger(limit) || + limit < 1 || + limit > 100 + ) { + throw new Error( + "offset must be a non-negative integer and limit must be an integer from 1 to 100", + ); + } + const rows = + args.action === "list_plugins" + ? await manager.listPlugins() + : await manager.listBundles(); + const entries = rows + .slice(offset, offset + limit) + .map(({ meta: _meta, ...row }) => + "rows" in row + ? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) } + : row, + ); + return JSON.stringify({ + entries, + total: rows.length, + nextOffset: offset + entries.length < rows.length ? offset + entries.length : null, + }); + } + case "set_plugin": + case "set_bundle": { + if (args.target === undefined || args.enabled === undefined) + throw new Error("target and enabled are required"); + return JSON.stringify( + await (args.action === "set_plugin" + ? manager.setPluginEnabled(args.target as PluginEntryId, args.enabled) + : manager.setBundleEnabled(args.target, args.enabled)), + ); + } + case "install_bundle": + if (args.target === undefined) throw new Error("target package spec is required"); + return JSON.stringify( + await manager.installBundle(args.target, { + ...(args.enabled === undefined ? {} : { enabled: args.enabled }), + ...(args.approvedBuilds === undefined + ? {} + : { approvedBuilds: args.approvedBuilds }), + ...(args.registry === undefined ? {} : { registry: args.registry }), + }), + ); + case "remove_bundle": + if (args.target === undefined) throw new Error("target bundle name is required"); + return JSON.stringify(await manager.removeBundle(args.target)); + /* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */ + default: + return assertNever(args.action); + } + }, + presentCall: (args) => ({ + card: "generic", + title: "Manage profile plugins", + kind: args.action.startsWith("list_") ? "read" : "other", + rawInput: args, + }), + }), + ); +} diff --git a/apps/web-ui/packages/boot/plugin-manager/src/types.ts b/apps/web-ui/packages/boot/plugin-manager/src/types.ts new file mode 100644 index 000000000..8513a9349 --- /dev/null +++ b/apps/web-ui/packages/boot/plugin-manager/src/types.ts @@ -0,0 +1,281 @@ +/** Public plugin management records shared with clients. */ +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { PluginLocalizedMeta } from "@deepseek-ai/dsh-package-manifest"; +import type { PluginInventoryEntry } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +export type { PluginEntryId } from "@deepseek-ai/dsh-host-plugin-inventory/types"; +import type { PluginEntryId } from "@deepseek-ai/dsh-host-plugin-inventory/types"; + +/** Reasons a profile control cannot modify its target. */ +export type ReadOnlyReason = "management-required" | "unaddressable"; + +/** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */ +export interface IncompatiblePlugin { + name: string; + version: string; + runtimeVersion: string; + /** Only the DSH peer ranges the running version does not satisfy. */ + peers: Record; +} + +/** Localizable management failure and optional external diagnostic. */ +export interface ManagementError { + code: + | ReadOnlyReason + | "unknown-plugin" + | "invalid-spec" + | "ambiguous-install" + | "not-bundle" + | "not-removable" + | "stop-profile" + | "bundle-in-use" + | "stale-approval" + | "incompatible-version" + | "operation-error"; + diagnostic?: string; + /** Present with `incompatible-version`: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[]; +} + +/** One running-profile entry and its persistent control availability. */ +export type PluginInfo = PluginInventoryEntry & + ( + | { patchId: string; readOnlyReason?: never } + | { patchId?: never; readOnlyReason: ReadOnlyReason } + ); + +/** One row a bundle's patch declares, with its live entry while the bundle contributes it. */ +export interface BundleRowInfo { + /** The row id as the patch declares it. */ + rowId: string; + /** The module the row names. */ + moduleName: string; + /** Local package display metadata, including rows whose bundle is disabled. */ + meta?: PluginLocalizedMeta; + /** The Loader entry carrying this row, when exactly one live entry has its id. */ + entryId?: PluginEntryId; +} + +/** One installed or installation-provided bundle. */ +export interface BundleInfo { + name: string; + version?: string; + /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */ + meta?: PluginLocalizedMeta; + /** Untranslated `description` of this bundle's package manifest. */ + description?: string; + /** Selected in the profile manifest; a load error means its layer was skipped. */ + enabled: boolean; + /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ + installed: boolean; + /** + * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local + * paths made absolute and the user information of an http(s) URL removed. + */ + source?: string; + /** + * Whether the installation ships the bundle for the person to switch on: named by the launcher's `OPTIONAL_BUNDLES`, + * held by the installation's dependencies, selected by no shipped template, and never removable. + */ + optional: boolean; + removable: boolean; + readOnlyReason?: ReadOnlyReason; + error?: ManagementError; + /** The rows the bundle's patch inserts, in declaration order; empty when the patch cannot be read. */ + rows: BundleRowInfo[]; + /** Ids of rows the bundle's patch changes without declaring them: the built-in rows it configures or disables. */ + overrides: string[]; +} + +/** A registry to install from: an http(s) URL, or null for the one pnpm's own configuration names. */ +export type Registry = string | null; + +/** The registries the manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names. */ +export interface PluginRegistries { + readonly registry: Registry; + readonly fallbackRegistries: readonly string[]; + /** The URL pnpm's own configuration names in the profile, read from pnpm; null when it could not be read. */ + readonly resolved: string | null; +} + +/** How a package operation failed, read off how it ended and what it printed. */ +export type PluginInstallFailureKind = + | "pnpm-missing" + | "timeout" + | "not-found" + | "no-matching-version" + | "network" + | "disk-full" + | "permission" + | "build-blocked" + | "integrity" + | "unknown"; + +/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */ +export interface PackageResult { + exitCode: number; + output: string; + truncated: boolean; + logPath: string; + /** Present when the run failed: what kind of failure its exit and output describe. */ + kind?: PluginInstallFailureKind; + /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ + timedOut?: boolean; + /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */ + incompatible?: IncompatiblePlugin[]; +} + +/** Persisted change and independently observed application outcome. */ +export interface ChangeResult { + changed: boolean; + /** `cancelled` is an installation the caller stopped, its files restored. */ + application: "applied" | "restart-required" | "overridden" | "failed" | "cancelled"; + /** Last attempted step; successful installation can proceed to enablement. */ + stage: "install" | "enable" | "remove"; + target: string; + enabled?: boolean; + error?: ManagementError; + /** Pre-existing inactive entries the operation left as they were. */ + warnings?: string[]; + packageResult?: PackageResult; + /** The bundle an installation added, once pnpm and the bundle check accepted it. */ + bundle?: string; + /** The installed bundle's manifest version, when declared; pnpm's `minimumReleaseAge` can make it older than the newest release. */ + version?: string; + /** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */ + pendingBuilds?: string[]; + /** Package script permissions saved before this installation attempt. */ + approvedBuilds?: string[]; + /** The registries the installation asked, in order; `packageResult` is the last one's run. */ + registries?: Registry[]; + /** + * What the last failed run could not reach or get an answer from: the registry it asked, or the host a git or + * tarball spec is fetched from, which no registry stands in for; absent for a failure neither explains. + */ + failedAt?: "registry" | "spec-host"; +} + +/** Identifies one installation from its start to its settlement, including its log chunks and cancellation. */ +export type PluginInstallRequestId = Branded<"PluginInstallRequestId">; + +/** Bundle installation defaults to activation; callers that offer cancellation supply their request id. */ +export interface InstallBundleOptions { + enabled?: boolean; + requestId?: PluginInstallRequestId; + /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */ + approvedBuilds?: string[]; + /** The registry asked first; absent, the configured one. The configured fallbacks follow while a registry is unreachable or stale. */ + registry?: Registry; +} + +/** Where an inspection asks. */ +export interface InspectOptions { + /** The registry asked first; absent, the configured one. */ + readonly registry?: Registry; +} + +/** The form one install spec takes, in pnpm's vocabulary. */ +export type InstallSpecKind = "registry" | "path" | "git" | "tarball"; + +/** Why a spec was refused before anything installed. */ +export type PluginInspectProblem = + | "invalid-spec" + | "already-installed" + | "not-found" + | "not-a-package" + | "not-a-bundle" + | "network" + | "unknown"; + +/** + * What a spec names, read before installing it: the registry's answer for a + * name, a directory's manifest for a path, and only the form for a git or + * tarball spec, whose package is known once pnpm has fetched it. + */ +export type PluginSpecInspection = + | { + readonly status: "accepted"; + readonly kind: InstallSpecKind; + readonly name?: string; + readonly version?: string; + /** `description` of the package manifest. */ + readonly description?: string; + /** Whether the package declares a bundle patch; null when the spec's form does not say. */ + readonly bundle: boolean | null; + /** The registry that answered for a name, and for the other forms the one an install of the spec asks first. */ + readonly registry: Registry; + /** The host a git spec or a tarball URL is fetched from, which no registry stands in for. */ + readonly host?: string; + } + | { + readonly status: "refused"; + readonly problem: PluginInspectProblem; + /** What pnpm, the registry, or the file system said. */ + readonly reason: string; + /** The registries asked, in order, when the refusal came from asking them; `reason` is the last one's. */ + readonly registries?: Registry[]; + }; + +/** The Host phase of one installation, before its install call settles; `installing` is announced once per registry asked. */ +export interface PluginInstallProgress { + readonly requestId: PluginInstallRequestId; + readonly phase: "installing" | "cancelling" | "applying"; + /** With `installing`: the registry this attempt asks, its one-based position, and how many the installation may ask. */ + readonly attempt?: { + readonly registry: Registry; + readonly index: number; + readonly total: number; + }; +} + +/** Cancellation is confirmed only after process exit and file restoration. */ +export interface PluginInstallCancellation { + readonly status: "cancelled" | "too-late" | "not-running"; +} + +/** One chunk of a pnpm run's output, as the run produces it. */ +export interface PluginInstallLogChunk { + /** The installation the run belongs to, when its caller supplied a request id. */ + readonly requestId?: PluginInstallRequestId; + /** The run the chunk belongs to. */ + readonly jobId: string; + /** The command line the run executes: pnpm's command name, then its arguments. */ + readonly argv: readonly string[]; + /** The directory the run executes in: the profile directory. */ + readonly cwd: string; + readonly stream: "stdout" | "stderr"; + readonly text: string; + /** Present on the run's last chunk: pnpm's exit code, null when it ended without one. */ + readonly exitCode?: number | null; +} + +/** What changed in the profile, for consumers that show it. */ +export interface PluginChange { + /** The operation that changed it. */ + readonly reason: "plugin" | "bundle" | "install" | "remove"; +} + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * The profile's plugins, bundles, or composition changed: a manager + * operation completed. A patch generation applied outside the manager, + * by HMR's watcher after a CLI or hand edit, announces nothing here. + * @mode emit + * @param change - what changed. + */ + "plugin-manager/changed"(change: PluginChange): void; + /** + * One chunk of a pnpm run's output, streamed as the run produces it. + * @mode emit + * @param chunk - the chunk and the run it belongs to. + */ + "plugin-manager/install-log"(chunk: PluginInstallLogChunk): void; + /** + * An installation moved between its Host phases. `installing` is announced once per registry the + * installation asks, with the attempt's registry and position; `cancelling` and `applying` once. + * @mode emit + * @param progress - the installation's request id and phase, with the attempt while installing. + */ + "plugin-manager/install-state"(progress: PluginInstallProgress): void; + } +} diff --git a/apps/web-ui/packages/client/connection/package.json b/apps/web-ui/packages/client/connection/package.json new file mode 100644 index 000000000..32709bb36 --- /dev/null +++ b/apps/web-ui/packages/client/connection/package.json @@ -0,0 +1,29 @@ +{ + "name": "@deepseek-ai/dsh-client-connection", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/schemastery": "*" + }, + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/connection/src/api-path.ts b/apps/web-ui/packages/client/connection/src/api-path.ts new file mode 100644 index 000000000..466151666 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/api-path.ts @@ -0,0 +1,7 @@ +/** + * The /api URL prefix — single source for both halves of the web transport. + * The node half registers this prefix on the web server. + */ + +/** Route prefix owning every api request (`/api` and `/api/`). */ +export const API_PATH = "/api"; diff --git a/apps/web-ui/packages/client/connection/src/api-request-trust.ts b/apps/web-ui/packages/client/connection/src/api-request-trust.ts new file mode 100644 index 000000000..45d474c05 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/api-request-trust.ts @@ -0,0 +1,124 @@ +/** + * Browser-trust fence for every /api request. Defends the two confused-deputy + * paths a browser opens against a local HTTP API — DNS rebinding (Host names + * the attacker's domain while the socket reaches this server) and cross-site + * requests fired from a malicious page. The Host fence binds every request, + * browser-looking or not: over plain HTTP a browser attaches neither Origin + * nor Fetch-Metadata to reads (images and navigations — those + * headers go only to trustworthy destinations), so an unmarked request may + * still be a rebound browser read and Host is the one header rebinding cannot + * forge. Non-browser and remote clients pass the same fence via loopback, + * deployment-derived LAN IP literals, or a declared `trustedHosts` authority. + * Network reachability and authentication stay out of scope: binding policy + * belongs to the webserver config, and this fence is not an auth layer. + */ + +import { isLoopbackHostname } from "./loopback-hostname.ts"; +import type { ConnectionTrustRequest } from "./rpc.ts"; + +function header(headers: ConnectionTrustRequest["headers"], name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined; + const value = headers[name]; + return typeof value === "string" ? value : undefined; +} + +/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ +function parseAuthority(authority: string): URL | undefined { + try { + // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. + return new URL(`http://${authority}`); + } catch { + return undefined; + } +} + +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) in canonical form: it must survive WHATWG parsing unchanged + * (case aside). Anything parsing would silently rewrite is refused as a typo + * that must fail the load loudly instead of being ignored until requests 403 + * or quietly changing the grant: URL parts beyond the authority + * (`harness.internal/path`, `user@harness.internal` — which would authorize + * the embedded hostname), stripped whitespace, a dangling colon or + * zero-padded port (which would broaden an intended exact-port grant to every + * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, + * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire + * carries). + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + const entryUrl = parseAuthority(entry); + if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return; + throw new Error( + `client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`, + ); +} + +/** + * Canonical form of a parsed authority: `hostname` when no port was written, + * else `hostname:port`. The port is judged from URL parses under both special + * schemes (their default ports differ, so `:80` and `:443` still count as + * explicit), never from the raw string, where WHATWG trimming would misread + * shapes like `host:port ` as port-less. + */ +function canonicalAuthority(entry: string, entryUrl: URL): string { + // An authority that parsed under http cannot fail under https. + const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port; + return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`; +} + +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry); + if (entryUrl === undefined) return false; + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host; + }); +} + +/** + * Decide whether one /api request may reach the RPC bridge. + * @param request - Node HTTP or Fetch request facts (headers). + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. + */ +export function isTrustedApiRequest( + request: ConnectionTrustRequest, + trustedHosts: readonly string[], +): boolean { + // Host fence (DNS-rebinding defense), applied to every request: the browser + // fills Host from the URL it believes it is talking to, so a rebound page + // carries the attacker's domain here even though the socket lands on this + // server. There is no marker shortcut — a browser read over plain HTTP + // (images and navigations) arrives with neither Origin nor + // Fetch-Metadata, indistinguishable from curl, and its response is readable + // by the rebound page. + const host = header(request.headers, "host"); + if (host === undefined) return false; + const hostUrl = parseAuthority(host); + if (hostUrl === undefined) return false; + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) + return false; + // Cross-site fence: modern browsers label the initiator relationship on + // every fetch; an explicit cross-site marker is refused regardless of Origin. + if (header(request.headers, "sec-fetch-site") === "cross-site") return false; + // Origin fence: when a browser attaches an Origin it must be exactly this + // authority (compared through the same normalization as the Host). Absent + // Origin is fine — the Host fence above already bound the request. The + // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, "origin"); + if (origin === undefined) return true; + try { + return new URL(origin).host === hostUrl.host; + } catch { + return false; + } +} diff --git a/apps/web-ui/packages/client/connection/src/browser-auth.ts b/apps/web-ui/packages/client/connection/src/browser-auth.ts new file mode 100644 index 000000000..6e4cbb854 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/browser-auth.ts @@ -0,0 +1,350 @@ +/** Browser-session authentication for the Host Connection carrier. */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { credentialKey } from "@deepseek-ai/dsh-credentials"; +import type { CredentialProvider, CredentialRecord } from "@deepseek-ai/dsh-credentials"; +import type { + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionTrustRequest, +} from "./rpc.ts"; + +const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session"); +const DAY_MILLISECONDS = 24 * 60 * 60 * 1000; +const SECRET_BYTES = 32; +const TOKEN_QUERY = "token"; +const COOKIE_PREFIX = "dsh-auth-"; +const COOKIE_PAYLOAD_VERSION = 1; +const STORED_SECRET_VERSION = 1; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; +const PROCESS_LAUNCH_TOKENS = new WeakMap(); + +interface StoredSecretPayload { + readonly version: typeof STORED_SECRET_VERSION; + readonly secret: string; +} + +interface BrowserCookiePayload { + readonly version: typeof COOKIE_PAYLOAD_VERSION; + readonly authority: string; + readonly issuedAt: number; + readonly expiresAt: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function encodeBase64Url(value: Uint8Array): string { + return Buffer.from(value) + .toString("base64") + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +function decodeBase64Url(value: string): Buffer | undefined { + if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined; + const padding = "=".repeat((4 - (value.length % 4)) % 4); + const decoded = Buffer.from(value.replaceAll("-", "+").replaceAll("_", "/") + padding, "base64"); + return encodeBase64Url(decoded) === value ? decoded : undefined; +} + +function processLaunchToken(owner: object): string { + const existing = PROCESS_LAUNCH_TOKENS.get(owner); + if (existing !== undefined) return existing; + const created = encodeBase64Url(randomBytes(SECRET_BYTES)); + PROCESS_LAUNCH_TOKENS.set(owner, created); + return created; +} + +function header(headers: ConnectionTrustRequest["headers"], name: string): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined; + const value = headers[name]; + return typeof value === "string" ? value : undefined; +} + +/** Canonical request authority used as the cookie name and signed audience. */ +function requestAuthority(headers: ConnectionTrustRequest["headers"]): string | undefined { + const host = header(headers, "host"); + if (host === undefined) return undefined; + try { + return new URL(`http://${host}`).host; + } catch { + return undefined; + } +} + +function canonicalSecret(value: unknown): Buffer | undefined { + if (typeof value !== "string") return undefined; + const decoded = decodeBase64Url(value); + if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined; + return decoded; +} + +function storedSecret(record: CredentialRecord | undefined): Buffer | undefined { + if (record === undefined) return undefined; + if ( + record.kind !== "grant" || + !isRecord(record.payload) || + record.payload.version !== STORED_SECRET_VERSION + ) { + throw new Error( + "client-connection: browser-session credential record has an unsupported format", + ); + } + const secret = canonicalSecret(record.payload.secret); + if (secret === undefined) { + throw new Error("client-connection: browser-session credential record has an invalid secret"); + } + return secret; +} + +function tokenMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual, "utf8"); + const expectedBytes = Buffer.from(expected, "utf8"); + return ( + actualBytes.byteLength === expectedBytes.byteLength && + timingSafeEqual(actualBytes, expectedBytes) + ); +} + +function cookieName(authority: string): string { + return COOKIE_PREFIX + encodeBase64Url(createHash("sha256").update(authority).digest()); +} + +/** Read the exact generated cookie without implementing general Cookie decoding. */ +function cookieValue(headerValue: string, name: string): string | undefined { + for (const segment of headerValue.split(";")) { + const at = segment.indexOf("="); + if (at === -1 || segment.slice(0, at).trim() !== name) continue; + return segment.slice(at + 1).trim(); + } + return undefined; +} + +/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */ +function sessionCookie( + name: string, + value: string, + expiresAt: number, + maxAgeSeconds: number, +): string { + return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`; +} + +function signature(secret: Buffer, body: string): Buffer { + return createHmac("sha256", secret).update(body).digest(); +} + +function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string { + const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8")); + return `v1.${body}.${encodeBase64Url(signature(secret, body))}`; +} + +function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined { + const parts = value.split("."); + const [version, body, encodedSignature] = parts; + if ( + parts.length !== 3 || + version !== "v1" || + body === undefined || + encodedSignature === undefined + ) { + return undefined; + } + const actualSignature = decodeBase64Url(encodedSignature); + if (actualSignature === undefined) return undefined; + const expectedSignature = signature(secret, body); + if ( + actualSignature.byteLength !== expectedSignature.byteLength || + !timingSafeEqual(actualSignature, expectedSignature) + ) + return undefined; + let decoded: unknown; + try { + const bodyBytes = decodeBase64Url(body); + if (bodyBytes === undefined) return undefined; + decoded = JSON.parse(bodyBytes.toString("utf8")); + } catch { + return undefined; + } + if ( + !isRecord(decoded) || + decoded.version !== COOKIE_PAYLOAD_VERSION || + typeof decoded.authority !== "string" || + !Number.isSafeInteger(decoded.issuedAt) || + !Number.isSafeInteger(decoded.expiresAt) + ) + return undefined; + return decoded as unknown as BrowserCookiePayload; +} + +async function initializeSecret(credentials: CredentialProvider): Promise { + const generated: StoredSecretPayload = { + version: STORED_SECRET_VERSION, + secret: encodeBase64Url(randomBytes(SECRET_BYTES)), + }; + const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { + if (current !== undefined) { + storedSecret(current); + return Promise.resolve(undefined); + } + return Promise.resolve({ kind: "grant", payload: generated }); + }); + const secret = storedSecret(record); + if (secret === undefined) { + throw new Error("client-connection: browser-session credential record was not created"); + } + return secret; +} + +/** + * Process launch-token exchange and persistent signed-cookie verification. + * Connection loads the credential provider's signing secret during activation + * and retains it for synchronous request authentication. + */ +export class BrowserAuth { + private readonly launchToken: string; + private readonly maxAgeMilliseconds: number; + + private constructor( + processOwner: object, + private readonly secret: Buffer, + maxAgeDays: number, + ) { + this.launchToken = processLaunchToken(processOwner); + this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS; + if ( + !Number.isSafeInteger(this.maxAgeMilliseconds) || + !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds) + ) { + throw new Error("client-connection: cookieMaxAgeDays exceeds the safe timestamp range"); + } + } + + /** + * Initialize browser authentication and create its durable signing secret + * when this Harness home has none. + * @param processOwner - root application context retaining one token across Connection reloads. + * @param credentials - persistent credential provider for the Web profile. + * @param maxAgeDays - positive absolute browser-cookie lifetime in days. + * @returns initialized authentication owner with the process owner's launch token. + */ + static async create( + processOwner: object, + credentials: CredentialProvider, + maxAgeDays: number, + ): Promise { + return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays); + } + + /** + * Add this process's launch token to the caller's application URL. + * @param baseUrl - clean browser URL whose authority and mount are preserved. + * @returns the same URL carrying the process token as its sole authentication input. + */ + authenticatedUrl(baseUrl: string): string { + const url = new URL(baseUrl); + url.searchParams.set(TOKEN_QUERY, this.launchToken); + return url.href; + } + + /** + * Authenticate an index request. A valid root query token mints the cookie + * and redirects to the directory-relative clean `./`; a valid cookie lets + * the caller serve the index; every other request receives the same minimal + * 401 response. + * @param req - incoming root or configured-index request. + * @param res - response owned when this method returns false. + * @returns true only when the caller may serve index.html. + */ + authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean { + /* v8 ignore next -- node:http always supplies url on server requests. */ + const url = new URL(req.url ?? "/", "http://dsh.invalid"); + const tokens = url.searchParams.getAll(TOKEN_QUERY); + if (tokens.length > 0) { + const authority = requestAuthority(req.headers); + if ( + req.method === "GET" && + url.pathname === "/" && + tokens.length === 1 && + authority !== undefined && + tokenMatches(tokens.join(""), this.launchToken) + ) { + const issuedAt = Date.now(); + const expiresAt = issuedAt + this.maxAgeMilliseconds; + const value = encodeCookie( + { + version: COOKIE_PAYLOAD_VERSION, + authority, + issuedAt, + expiresAt, + }, + this.secret, + ); + res.writeHead(303, { + "cache-control": "no-store", + location: "./", + "referrer-policy": "no-referrer", + "set-cookie": sessionCookie( + cookieName(authority), + value, + expiresAt, + Math.floor(this.maxAgeMilliseconds / 1000), + ), + }); + res.end(); + return false; + } + if (req.method === "GET" && url.pathname === "/" && this.isAuthenticated(req)) { + res.writeHead(303, { + "cache-control": "no-store", + location: "./", + "referrer-policy": "no-referrer", + }); + res.end(); + return false; + } + this.writeUnauthorized(req, res); + return false; + } + if (this.isAuthenticated(req)) return true; + this.writeUnauthorized(req, res); + return false; + } + + /** + * Verify the authority-bound browser cookie on a Host request. + * @param request - request headers carrying Host and Cookie. + * @returns true only for an unexpired cookie signed by this activation's loaded secret. + */ + isAuthenticated(request: ConnectionTrustRequest): boolean { + const authority = requestAuthority(request.headers); + const rawCookie = header(request.headers, "cookie"); + if (authority === undefined || rawCookie === undefined) return false; + const value = cookieValue(rawCookie, cookieName(authority)); + if (value === undefined) return false; + const payload = decodeCookie(value, this.secret); + if (payload === undefined || payload.authority !== authority) return false; + const now = Date.now(); + return ( + payload.issuedAt <= now && + payload.expiresAt > now && + payload.expiresAt > payload.issuedAt && + payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds + ); + } + + private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { + res.writeHead(401, { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }); + res.end( + req.method === "HEAD" + ? undefined + : "dsh web authentication required; reopen the URL printed by dsh web.\n", + ); + } +} diff --git a/apps/web-ui/packages/client/connection/src/client/api.ts b/apps/web-ui/packages/client/connection/src/client/api.ts new file mode 100644 index 000000000..a351c9518 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/api.ts @@ -0,0 +1,25 @@ +/** Browser-safe Connection protocol and shared application value exports. */ + +export type { + ClientRequest, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + ServerResponse, +} from "../rpc.ts"; +export { RpcId, transportError } from "../rpc.ts"; +export type { SessionId, SessionEvent } from "@deepseek-ai/dsh-session/types"; +export type { MessageId } from "@deepseek-ai/dsh-llm/brand"; +export type { ContentBlock, StreamChunk } from "@deepseek-ai/dsh-llm/types"; + +import type { RpcResponse, RpcResult } from "../rpc.ts"; + +/** + * Return the business result carried by an RPC response. + * @param response - RPC response to unwrap. + * @returns the response's business result. + */ +export function resultOf(response: RpcResponse): RpcResult { + return response.result; +} diff --git a/apps/web-ui/packages/client/connection/src/client/connection.ts b/apps/web-ui/packages/client/connection/src/client/connection.ts new file mode 100644 index 000000000..0e67920c3 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/connection.ts @@ -0,0 +1,334 @@ +/** Connection generation readiness, cancellation, and continuous recovery. */ +import { resolveConnectionConfig, type ConnectionRecoveryConfig } from "../recovery-config.ts"; + +export type { ConnectionRecoveryConfig } from "../recovery-config.ts"; + +/** Stable Host facts delivered by one established Remote event generation. */ +export interface ConnectionHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string; +} + +/** One successfully established Host generation. */ +export interface ConnectionGeneration { + /** Monotone generation number within this Client runtime. */ + readonly id: number; + /** Host facts carried by this generation's opening frame. */ + readonly host: ConnectionHostInfo; +} + +const MANUAL_RECONNECT = new Error("connection: manual reconnect requested"); +const NETWORK_STATE_CHANGED = new Error("connection: browser network state changed"); + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done(): void { + clearTimeout(t); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + resolve(); + }, + { once: true }, + ); + }); +} + +/** Connection lifecycle state published after the first attempt has an outcome. */ +export type ConnectionState = "connected" | "disconnected" | "connecting"; + +/** Connection-generation callbacks owned by API Gateway. */ +export interface ConnectionSinks { + /** After the generation source reports ready, first connect included. */ + onConnected?: (host: ConnectionHostInfo) => void; + /** State transitions after the initial attempt has an outcome. Equivalent states are deduplicated. */ + onStateChange?: (state: ConnectionState) => void; + /** Start one fresh physical-carrier attempt before each logical retry. */ + onReconnectRequested?: () => void; +} + +/** + * One long-lived source defining a Connection generation. The source must + * attach its incremental listeners before calling `ready`, then remain pending + * until the generation is lost or `signal` aborts. On abort it must stop + * delivery, release its resources, and settle before a replacement can start. + * @param signal - cancellation for the current generation. + * @param ready - one-shot report that incremental delivery is attached. + * @returns a promise settling only when this generation ends or fails. + */ +export type ConnectionGenerationSource = ( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, +) => Promise; + +/** + * Opens the registered generation source, reconnecting with exponential backoff on loss. + * State (generation/attempt) is instance-private, never in the store. + * Sink exceptions do not kill the generation loop. + */ +export class ConnectionController { + private generation = 0; + private attempt = 0; + private current: AbortController | null = null; + private retryDelay: AbortController | null = null; + private running = false; + private immediateRetry = false; + private networkAvailable = true; + private lastState: ConnectionState | undefined; + private readonly config: Required; + + constructor( + private readonly source: ConnectionGenerationSource, + private readonly sinks: ConnectionSinks = {}, + config: ConnectionRecoveryConfig = {}, + ) { + this.config = resolveConnectionConfig(config); + } + + /** Idempotent: begin the connect/pump/reconnect loop. */ + start(): void { + if (this.running) return; + this.running = true; + void this.loop(); + } + + /** Stop the loop and abort the current generation source. */ + stop(): void { + this.running = false; + this.current?.abort(); + this.current = null; + this.retryDelay?.abort(); + this.retryDelay = null; + } + + /** Reset the retry sequence and replace the current generation or retry delay immediately. */ + reconnect(): void { + if (!this.running) return; + this.attempt = 0; + this.immediateRetry = true; + this.emitState("connecting"); + if (!this.isRunning()) return; + this.current?.abort(MANUAL_RECONNECT); + this.retryDelay?.abort(MANUAL_RECONNECT); + } + + /** + * Suspend automatic retries while offline and restart backoff when the network returns. + * @param available - whether the browser reports network access. + */ + setNetworkAvailable(available: boolean): void { + if (this.networkAvailable === available) return; + this.networkAvailable = available; + this.attempt = 0; + this.immediateRetry = false; + if (!this.running) return; + this.emitState(available ? "connecting" : "disconnected"); + if (!this.isRunning()) return; + this.current?.abort(NETWORK_STATE_CHANGED); + this.retryDelay?.abort(NETWORK_STATE_CHANGED); + } + + private backoffCap(attempt: number): number { + const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config; + return Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1)); + } + + private backoffDelay(attempt: number): number { + const cap = this.backoffCap(attempt); + return cap / 2 + Math.random() * (cap / 2); + } + + /** Re-read retry inputs after a potentially reentrant state sink. */ + private isRetryInterrupted(immediate: boolean): boolean { + return this.immediateRetry || (!this.networkAvailable && !immediate); + } + + /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */ + private isRunning(): boolean { + return this.running; + } + + /** Re-read both mutable liveness guards after a potentially reentrant sink. */ + private isGenerationActive(controller: AbortController): boolean { + return this.isRunning() && !controller.signal.aborted; + } + + private async loop(): Promise { + let retry = false; + while (this.running) { + if (!this.networkAvailable && !this.immediateRetry) { + const retryDelay = new AbortController(); + this.retryDelay = retryDelay; + this.emitState("disconnected"); + await waitForAbort(retryDelay.signal); + if (this.retryDelay === retryDelay) this.retryDelay = null; + if (!this.isRunning()) return; + retry = true; + continue; + } + + let manualAttempt = false; + if (retry) { + const immediate = this.immediateRetry; + this.immediateRetry = false; + if (immediate) this.attempt = 0; + manualAttempt = immediate; + const attempt = ++this.attempt; + this.emitState("connecting"); + if (!this.isRunning()) return; + if (this.isRetryInterrupted(immediate)) continue; + if (!immediate) { + const retryDelay = new AbortController(); + this.retryDelay = retryDelay; + await sleep(this.backoffDelay(attempt), retryDelay.signal); + if (this.retryDelay === retryDelay) this.retryDelay = null; + if (!this.isRunning()) return; + if (retryDelay.signal.aborted) continue; + } + console.warn(`[connection] connection lost, retry #${String(attempt)}`); + this.callSink(() => { + this.sinks.onReconnectRequested?.(); + }); + if (!this.isRunning()) return; + } + + const gen = ++this.generation; + const ac = new AbortController(); + this.current = ac; + + let sourceReady = false; + let resolveReady!: (host: ConnectionHostInfo) => void; + let rejectReady!: (error: Error) => void; + let rejectSourceLost!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const sourceLost = new Promise((_resolve, reject) => { + rejectSourceLost = reject; + }); + const reportReady = (host: ConnectionHostInfo): void => { + if (sourceReady || gen !== this.generation || !this.isGenerationActive(ac)) return; + sourceReady = true; + resolveReady(host); + }; + + const failed = new Promise((resolve) => { + const settle = (): void => { + if (gen === this.generation && !ac.signal.aborted) ac.abort(); + resolve(); + }; + void Promise.resolve() + .then(() => this.source(ac.signal, reportReady)) + .then( + () => { + const error = new Error("connection generation ended"); + if (!sourceReady) rejectReady(error); + rejectSourceLost(error); + settle(); + }, + (error: unknown) => { + const failure = + error instanceof Error + ? error + : new Error("connection generation failed", { cause: error }); + if (!sourceReady) rejectReady(failure); + rejectSourceLost(failure); + settle(); + }, + ); + }); + + try { + const host = await Promise.race([waitForReady(ready, this.config, ac.signal), sourceLost]); + if (ac.signal.aborted) throw new Error("generation aborted during readiness handshake"); + this.attempt = 0; + this.emitState("connected"); + // A state sink may synchronously stop this controller. + if (this.isGenerationActive(ac)) { + this.callSink(() => { + this.sinks.onConnected?.(host); + }); + } + } catch (error) { + if (!ac.signal.aborted) ac.abort(error); + } + + await failed; + if (!this.isRunning()) return; + if (manualAttempt) this.attempt = 0; + retry = true; + } + } + + /** Deduplicated state emission (sink isolation applies). */ + private emitState(state: ConnectionState): void { + if (this.lastState === state) return; + this.lastState = state; + this.callSink(() => this.sinks.onStateChange?.(state)); + } + + /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ + private callSink(fn: () => void): void { + try { + fn(); + } catch (error) { + console.error("[connection] connection sink threw:", error); + } + } +} + +/** Report a slow handshake before the hard deadline ends its generation. */ +function waitForReady( + ready: Promise, + config: Required, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const warning = setTimeout(() => { + console.warn( + `[connection] generation is still not ready after ${String(config.generationReadyWarnMs)}ms`, + ); + }, config.generationReadyWarnMs); + const timeout = setTimeout(() => { + const error = new Error( + `connection generation was not ready within ${String(config.generationReadyTimeoutMs)}ms`, + ); + console.warn(`[connection] ${error.message}; cancelling generation`); + finish({ error }); + }, config.generationReadyTimeoutMs); + const aborted = (): void => { + finish({ error: new Error("connection generation aborted", { cause: signal.reason }) }); + }; + const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => { + if (settled) return; + settled = true; + clearTimeout(warning); + clearTimeout(timeout); + signal.removeEventListener("abort", aborted); + if ("error" in outcome) reject(outcome.error); + else resolve(outcome.value); + }; + signal.addEventListener("abort", aborted, { once: true }); + void ready.then( + (value) => { + finish({ value }); + }, + (error: unknown) => { + finish({ error: error as Error }); + }, + ); + }); +} diff --git a/apps/web-ui/packages/client/connection/src/client/index.ts b/apps/web-ui/packages/client/connection/src/client/index.ts new file mode 100644 index 000000000..96c9f193b --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/index.ts @@ -0,0 +1,351 @@ +/** Browser wire client: Remote transport and connection generations. */ +import type { Context } from "@deepseek-ai/cordis"; +import { + ConnectionController, + type ConnectionRecoveryConfig, + type ConnectionGeneration, + type ConnectionGenerationSource, + type ConnectionSinks, + type ConnectionState, +} from "./connection.ts"; +import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from "./rpc.ts"; +import { isLoopbackHostname } from "../loopback-hostname.ts"; +import type { ClientConnectionRpc } from "../rpc.ts"; +import { resolveConnectionConfig } from "../recovery-config.ts"; + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * A connection generation was established. Wire-derived caches must + * repull; long-lived streams own their own resume and baseline lifecycle. + * @mode emit + */ + "connection/reset"(): void; + } +} + +// ---- Browser-safe protocol and shared value re-exports ---- +export type { + MessageId, + RpcRequest, + RpcResponse, + RpcResult, + ClientRequest, + ServerResponse, + RpcMessage, + SessionId, + SessionEvent, + ContentBlock, + StreamChunk, +} from "./api.ts"; +export { RpcId, transportError } from "./api.ts"; + +// Connection loop types are public through ConnectionHandle.start; the +// controller remains package-internal. +export type { + ConnectionRecoveryConfig, + ConnectionGeneration, + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionSinks, + ConnectionState, +} from "./connection.ts"; +export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult } from "../rpc.ts"; +export type { RpcFetch } from "./rpc.ts"; + +/** Observable identity and Host facts for the active connection generation. */ +export interface ConnectionGenerationState { + /** Active generation, or undefined before readiness and while reconnecting. */ + getSnapshot(): ConnectionGeneration | undefined; + /** Subscribe to generation establishment, replacement, and loss. */ + subscribe(listener: () => void): () => void; +} + +/** Observable recovery lifecycle of the owned Connection loop. */ +export interface ConnectionStateSource { + /** Current state, or undefined before the first connection outcome. */ + getSnapshot(): ConnectionState | undefined; + /** Subscribe to state changes. */ + subscribe(listener: () => void): () => void; +} + +/** Required services (none — this is the wire root). */ +export const inject: string[] = []; + +/** + * Physical carrier selected when the Connection service is installed. The + * served web app omits it and gets HTTP + WebSocket; a shell that owns a + * different transport (the worker preview's postMessage tunnel) provides both + * halves instead of forking this plugin. + */ +export interface ClientTransportHooks { + /** + * Already decoded logical RPC carrier. When present it replaces the HTTP + * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process + * Host such as a test mock plugs in here). + */ + rpc?: ClientConnectionRpc; + /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */ + fetch?: RpcFetch; + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */ + openStream?: RpcStreamOpen; + /** + * Bundle transport for the module system, present when the carrier also owns + * bundle bytes (the worker tunnel). Absent in the served web app, whose + * bundles load over HTTP. + */ + loadBundle?(url: string): Promise; + /** + * The transport owner declares the page owns the Host outright: the Host + * runs inside a worker this page spawned, so no other party can reach it and + * the loopback stand-in for "the operator's own machine" is vacuous. + * `ctx.connection.isLoopback` then reports the privileged surface reachable + * regardless of the page authority. Only a shell that assembles its own + * transport can set this; served pages never carry the global at all. + */ + ownsHost?: boolean; + /** HTTP origin of a shell-owned Host when its WebSocket uses a different page origin. */ + streamBaseUrl?: string; +} + +/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */ +interface ClientTransportGlobal { + __DSH_TRANSPORT__?: ClientTransportHooks; + __DSH_CONNECTION_RECOVERY__?: unknown; +} + +/** Browser location fields used to classify loopback authority. */ +export interface ConnectionLocation { + readonly hostname: string; +} + +/** Instance-local inputs for installing a Connection service. */ +export interface ConnectionInstallOptions { + /** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */ + readonly transport?: ClientTransportHooks; + /** Reconnect timing overrides; omitted fields use controller defaults. */ + readonly recovery?: ConnectionRecoveryConfig; + /** Page location; omit for a non-browser composition. */ + readonly location?: ConnectionLocation; +} + +/** + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. + */ +export interface ConnectionHandle { + /** + * Whether the privileged surface is reachable: the page authority is + * loopback, the transport declares the page owns the Host + * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. + */ + readonly isLoopback: boolean; + /** Current Remote event generation and the Host facts carried by its opening frame. */ + readonly generation: ConnectionGenerationState; + /** Current recovery lifecycle for connection-specific consumers. */ + readonly state: ConnectionStateSource; + /** Generic logical RPC channels over the same Connection transport. */ + readonly rpc: ClientConnectionRpc; + /** Reset retry progression and replace the current attempt immediately. */ + reconnect(): void; + /** + * Register the sole source defining Host generations. The source reports + * ready only after its incremental listeners are attached. + * @param source - long-lived generation source owned by the push carrier. + * @returns disposer withdrawing the source and stopping an active loop. + */ + registerGenerationSource(source: ConnectionGenerationSource): () => void; + /** + * Start the connect/reconnect loop with the consumer's state callbacks. + * API Gateway owns the loop; a second call throws. + * @param sinks - connection-state callbacks. + * @param config - explicit timing overrides; omitted fields use Host bootstrap timing. + * @returns lifecycle controls for the loop. + */ + start(sinks: ConnectionSinks, config?: ConnectionRecoveryConfig): ConnectionLoop; +} + +/** Controls retained by the sole owner of a running connection loop. */ +export interface ConnectionLoop { + /** Stop the loop and withdraw its active generation. */ + stop(): void; +} + +interface ConnectionOwner { + readonly token: object; + readonly source: ConnectionGenerationSource; + readonly controller: ConnectionController; + readonly stopNetworkWatch: () => void; +} + +interface BrowserNetworkTarget { + readonly navigator?: { readonly onLine?: boolean }; + addEventListener(type: "online" | "offline", listener: () => void): void; + removeEventListener(type: "online" | "offline", listener: () => void): void; +} + +function watchBrowserNetwork(controller: ConnectionController): () => void { + const browser = (globalThis as { readonly window?: BrowserNetworkTarget }).window; + const initiallyAvailable = browser?.navigator?.onLine; + if (browser === undefined || initiallyAvailable === undefined) return () => {}; + const online = (): void => { + controller.setNetworkAvailable(true); + }; + const offline = (): void => { + controller.setNetworkAvailable(false); + }; + controller.setNetworkAvailable(initiallyAvailable); + browser.addEventListener("online", online); + browser.addEventListener("offline", offline); + return () => { + browser.removeEventListener("online", online); + browser.removeEventListener("offline", offline); + }; +} + +/** + * Install one Context-owned Connection service from explicit composition inputs. + * @param ctx - client Cordis context. + * @param options - physical carrier, reconnect timing, and page location. + */ +export function installConnection(ctx: Context, options: ConnectionInstallOptions = {}): void { + const pageLocation = options.location; + const transport = options.transport; + const recovery = options.recovery ?? {}; + const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream); + let generationSource: ConnectionGenerationSource | undefined; + let owner: ConnectionOwner | undefined; + let generationId = 0; + let generation: ConnectionGeneration | undefined; + let state: ConnectionState | undefined; + const generationListeners = new Set<() => void>(); + const stateListeners = new Set<() => void>(); + const publishGeneration = (next: ConnectionGeneration | undefined): void => { + if (Object.is(generation, next)) return; + generation = next; + for (const listener of [...generationListeners]) { + try { + listener(); + } catch (error) { + console.error("[connection] generation listener threw:", error); + } + } + }; + const publishState = (next: ConnectionState | undefined): void => { + if (state === next) return; + state = next; + for (const listener of [...stateListeners]) { + try { + listener(); + } catch (error) { + console.error("[connection] state listener threw:", error); + } + } + }; + const releaseOwner = (current: ConnectionOwner): void => { + if (owner !== current) return; + owner = undefined; + current.stopNetworkWatch(); + current.controller.stop(); + publishGeneration(undefined); + publishState(undefined); + }; + const handle: ConnectionHandle = { + isLoopback: + transport?.ownsHost === true || + pageLocation === undefined || + isLoopbackHostname(pageLocation.hostname), + generation: { + getSnapshot: () => generation, + subscribe: (listener) => { + generationListeners.add(listener); + return () => { + generationListeners.delete(listener); + }; + }, + }, + state: { + getSnapshot: () => state, + subscribe: (listener) => { + stateListeners.add(listener); + return () => { + stateListeners.delete(listener); + }; + }, + }, + rpc, + reconnect() { + owner?.controller.reconnect(); + }, + registerGenerationSource(source) { + if (generationSource !== undefined) { + throw new Error("connection: a generation source is already registered"); + } + generationSource = source; + return () => { + if (generationSource !== source) return; + generationSource = undefined; + const current = owner; + if (current?.source === source) releaseOwner(current); + }; + }, + start(sinks, config) { + if (owner !== undefined) + throw new Error("connection: the stream loop is already owned by another consumer"); + const source = generationSource; + if (source === undefined) throw new Error("connection: no generation source is registered"); + const token = {}; + const ownsGeneration = (): boolean => owner?.token === token; + const controller = new ConnectionController( + source, + { + ...sinks, + onConnected: (host) => { + const nextGeneration = { id: ++generationId, host }; + publishGeneration(nextGeneration); + if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return; + sinks.onConnected?.(host); + }, + onStateChange: (state) => { + if (state !== "connected") { + publishGeneration(undefined); + } + if (!ownsGeneration()) return; + publishState(state); + sinks.onStateChange?.(state); + }, + }, + { ...recovery, ...config }, + ); + const current = { + token, + source, + controller, + stopNetworkWatch: watchBrowserNetwork(controller), + }; + owner = current; + controller.start(); + return { + stop: () => { + releaseOwner(current); + }, + }; + }, + }; + ctx.provide("connection", handle); +} + +/** + * Client plugin body: read the page composition and install its Connection service. + * @param ctx - client Cordis context. + */ +export function apply(ctx: Context): void { + const globals = globalThis as ClientTransportGlobal; + const pageLocation = typeof location === "undefined" ? undefined : location; + const transport = globals.__DSH_TRANSPORT__; + installConnection(ctx, { + ...(transport === undefined ? {} : { transport }), + recovery: resolveConnectionConfig(globals.__DSH_CONNECTION_RECOVERY__), + ...(pageLocation === undefined ? {} : { location: pageLocation }), + }); +} diff --git a/apps/web-ui/packages/client/connection/src/client/random-uuid.ts b/apps/web-ui/packages/client/connection/src/client/random-uuid.ts new file mode 100644 index 000000000..fb83e87cb --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/random-uuid.ts @@ -0,0 +1,14 @@ +/** Browser-safe UUID generation for client-side wire correlation. */ + +/** + * Generate an RFC 4122 version 4 UUID without requiring a secure context. + * @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins. + */ +export function randomUuid(): string { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40); + view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80); + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/apps/web-ui/packages/client/connection/src/client/rpc.ts b/apps/web-ui/packages/client/connection/src/client/rpc.ts new file mode 100644 index 000000000..d0b5f7fdf --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/client/rpc.ts @@ -0,0 +1,220 @@ +/** Browser caller for generic Connection unary RPC channels. */ + +import { RpcId, type ClientRequest, type RpcId as RpcIdType } from "../rpc.ts"; +import type { ClientConnectionRpc, ConnectionRpcResult } from "../rpc.ts"; +import { randomUuid } from "./random-uuid.ts"; + +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/; +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/; + +/** + * Transport this caller posts through; same signature as the global `fetch`. + * Receives the document-relative route so a carrier resolves it against its own base. + */ +export type RpcFetch = (input: string | URL, init: RequestInit) => Promise; + +/** Worker-local opener for decoded Gateway Remote streams; `uplink` carries the Client's items for the stream. */ +export type RpcStreamOpen = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, +) => AsyncIterable; + +/** + * Create the browser-backed generic RPC caller. + * @param doFetch - transport override; defaults to the page's global fetch. + * @param openStream - optional worker-local Gateway stream carrier. + * @returns caller that owns request correlation and response-envelope validation. + */ +export function createWebConnectionRpc( + doFetch?: RpcFetch, + openStream?: RpcStreamOpen, +): ClientConnectionRpc { + const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init)); + return { + async call(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint); + const rpcId = RpcId(randomUuid()); + const message: ClientRequest = { + type: "client-request", + rpcId, + method: endpoint, + payload, + }; + // The channel key is absolute; a page posts the document-relative form, and + // a carrier that resolves against the Host root accepts the same form. + const route = `${channel}/${endpoint}`.slice(1); + const response = await send(route, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + ...(signal === undefined ? {} : { signal }), + }); + if (!response.ok) { + throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`); + } + const mediaType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const full = + mediaType === "multipart/form-data" + ? await parseBinaryResponse(response) + : parseConnectionResponse(await response.json()); + signal?.throwIfAborted(); + if (full.rpcId !== rpcId) { + throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`); + } + return full.result; + }, + ...(openStream === undefined + ? {} + : { + open(channel, endpoint, payload, signal, uplink) { + assertTarget(channel, endpoint); + if (channel !== "/api") { + throw new Error( + `connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`, + ); + } + return openStream(endpoint, payload, signal, uplink); + }, + }), + }; +} + +async function parseBinaryResponse( + response: Response, +): Promise> { + const body = await response.formData(); + const fields = new Map(); + for (const [name, value] of body) { + if (fields.has(name)) throw new TypeError("connection: invalid binary response fields"); + fields.set(name, value); + } + const metadata = fields.get("metadata"); + fields.delete("metadata"); + if (typeof metadata !== "string") { + throw new TypeError("connection: invalid binary response fields"); + } + const envelope: unknown = JSON.parse(metadata); + const full = parseConnectionResponse(envelope); + if ( + !full.result.ok || + !isRecord(envelope) || + !Array.isArray(envelope.attachments) || + envelope.attachments.length === 0 + ) { + throw new TypeError("connection: invalid binary response result"); + } + const root = { value: full.result.value }; + for (const attachment of envelope.attachments) { + if ( + !isRecord(attachment) || + attachment.codec !== "bytes" || + typeof attachment.part !== "string" || + !Array.isArray(attachment.path) + ) { + throw new TypeError("connection: invalid binary response attachment"); + } + const data = fields.get(attachment.part); + fields.delete(attachment.part); + if (!(data instanceof Blob)) { + throw new TypeError("connection: invalid binary response fields"); + } + let parent: object = root; + let key: string | number = "value"; + for (const segment of attachment.path) { + const value: unknown = Reflect.get(parent, key); + if (typeof value !== "object" || value === null) { + throw new TypeError("connection: invalid binary response path"); + } + if (Array.isArray(value)) { + if ( + typeof segment !== "number" || + !Number.isSafeInteger(segment) || + segment < 0 || + segment >= value.length + ) { + throw new TypeError("connection: invalid binary response path"); + } + } else if (typeof segment !== "string") { + throw new TypeError("connection: invalid binary response path"); + } + if (!Object.hasOwn(value, segment)) + throw new TypeError("connection: invalid binary response path"); + parent = value; + key = segment; + } + if (Reflect.get(parent, key) !== null) + throw new TypeError("connection: invalid binary response placeholder"); + Object.defineProperty(parent, key, { + value: new Uint8Array(await data.arrayBuffer()), + enumerable: true, + writable: true, + configurable: true, + }); + } + if (fields.size !== 0) throw new TypeError("connection: invalid binary response fields"); + return { + rpcId: full.rpcId, + result: { ok: true, value: root.value }, + }; +} + +function parseConnectionResponse(value: unknown): { + readonly rpcId: RpcIdType; + readonly result: ConnectionRpcResult; +} { + if (!isRecord(value) || value.type !== "server-response" || typeof value.rpcId !== "string") { + throw new TypeError("connection: invalid server-response envelope"); + } + const result = value.result; + if (!isRecord(result)) throw new TypeError("connection: invalid server-response result"); + if (result.ok === true) { + return { + rpcId: RpcId(value.rpcId), + result: { ok: true, value: result.value }, + }; + } + if (result.ok !== false || !isRecord(result.error)) { + throw new TypeError("connection: invalid server-response result"); + } + const error = result.error; + if ( + typeof error.code !== "string" || + typeof error.message !== "string" || + !isRecord(error.details) + ) { + throw new TypeError("connection: invalid server-response failure"); + } + return { + rpcId: RpcId(value.rpcId), + result: { + ok: false, + error: { code: error.code, message: error.message, details: error.details }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertTarget(channel: string, endpoint: string): void { + const segments = endpoint.split("/"); + if ( + !CHANNEL_PATTERN.test(channel) || + segments.some( + (segment) => + segment === "" || + segment === "." || + segment === ".." || + !ENDPOINT_SEGMENT_PATTERN.test(segment), + ) + ) { + throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`); + } +} diff --git a/apps/web-ui/packages/client/connection/src/http-bridge.ts b/apps/web-ui/packages/client/connection/src/http-bridge.ts new file mode 100644 index 000000000..ca5cf5221 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/http-bridge.ts @@ -0,0 +1,130 @@ +/** + * node:http ↔ WHATWG fetch bridge for the /api transport (host side of the + * web carrier; the fetch-shaped handler itself is transport-agnostic). + */ + +import type { IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import type { ConnectionFetchHandler } from "./rpc.ts"; + +/** Default carrier cap for all HTTP RPC bodies: sized for the default + * aggregate image limit (200 MiB) after base64 expansion plus envelope + * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers + * each body in memory, so this cap is also the per-request resident bound. */ +export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024; + +interface BridgeServerResponse { + readonly destroyed: boolean; + readonly writableEnded: boolean; + on(event: "close", listener: () => void): this; + off(event: "close" | "drain", listener: () => void): this; + once(event: "close" | "drain", listener: () => void): this; + writeHead(statusCode: number, headers?: Record): unknown; + write(chunk: Uint8Array): boolean; + end(): unknown; +} + +/** + * Bridge one node:http request to the fetch-shaped handler (client close + * aborts; response writes respect backpressure and stop on disconnect). + * @param req - incoming node:http request. + * @param res - node:http response the bridge writes and owns to completion. + * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. + */ +export async function bridge( + req: IncomingMessage, + res: BridgeServerResponse, + apiHandler: ConnectionFetchHandler, + maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, +): Promise { + const abort = new AbortController(); + // Client-disconnect detection MUST hang off the response, not the request: + // since Node 16, IncomingMessage 'close' fires as soon as the request body is + // fully consumed (immediately for a bodyless GET), which would abort a + // streaming response right after open. ServerResponse 'close' fires on connection teardown; + // writableEnded distinguishes a normal end() from the client going away. + res.on("close", () => { + if (!res.writableEnded) abort.abort(); + }); + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? "/", "http://dsh.internal"); + const method = req.method ?? "GET"; + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === "string") as [ + string, + string, + ][], + ); + const bodyMode = apiHandler.requestBodyMode({ method, url }); + let request: Request; + if (bodyMode === "buffered") { + const declaredLength = req.headers["content-length"]; + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + const chunks: Buffer[] = []; + let received = 0; + for await (const chunk of req) { + const buffer = chunk as Buffer; + received += buffer.byteLength; + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: "close" }); + res.end(); + req.destroy(); + return; + } + chunks.push(buffer); + } + request = new Request(url, { + method, + headers, + ...(chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}), + signal: abort.signal, + }); + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: "half", + } as RequestInit & { duplex: "half" }); + } + const response = await apiHandler.fetch(request); + const requestUnread = bodyMode === "streaming" && !req.readableEnded; + const responseHeaders = Object.fromEntries(response.headers.entries()); + res.writeHead( + response.status, + requestUnread ? { ...responseHeaders, connection: "close" } : responseHeaders, + ); + if (response.body === null) { + res.end(); + if (requestUnread) req.destroy(); + return; + } + for await (const chunk of response.body) { + // Drain without writing after disconnect: cancelling Node multipart bodies + // can race their producer and reject with ERR_INVALID_STATE. + if (abort.signal.aborted) continue; + // Backpressure: a false return means the socket buffer is full — wait for drain + // instead of buffering unboundedly (slow or suspended consumers). 'close' also + // resolves so a mid-wait disconnect cannot park this loop forever. + if (!res.write(chunk) && !res.destroyed) { + await new Promise((resolve) => { + const done = (): void => { + res.off("drain", done); + res.off("close", done); + resolve(); + }; + res.once("drain", done); + res.once("close", done); + }); + } + } + res.end(); + if (requestUnread) req.destroy(); +} diff --git a/apps/web-ui/packages/client/connection/src/index.ts b/apps/web-ui/packages/client/connection/src/index.ts new file mode 100644 index 000000000..c9476b4c7 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/index.ts @@ -0,0 +1,173 @@ +/** Host HTTP bridge for browser-client RPC. */ +import type { Context } from "@deepseek-ai/cordis"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import z from "@deepseek-ai/schemastery"; +import type {} from "@deepseek-ai/dsh-attachment"; +import type {} from "@deepseek-ai/dsh-credentials"; +// Activates the webServer Context merge used below. +import type { WebRoute } from "@deepseek-ai/dsh-host-webserver"; +import { API_PATH } from "./api-path.ts"; +import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from "./http-bridge.ts"; +import { assertTrustedAuthority } from "./api-request-trust.ts"; +import { BrowserAuth } from "./browser-auth.ts"; +import { HostConnectionService } from "./rpc-host.ts"; +import { + ConnectionRecoveryConfigSchema, + resolveConnectionConfig, + type ConnectionRecoveryConfig, +} from "./recovery-config.ts"; + +export type { + PeerAdmission, + ConnectionFetchMethod, + ConnectionFetchHandler, + ConnectionFetchRoute, + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionRpcEndpointMatcher, + ConnectionRpcAttachment, + ConnectionRpcFailure, + ConnectionRpcHandler, + ConnectionRpcHandlerResult, + ConnectionRequestRejection, + ConnectionRpcResult, + ConnectionRequestBodyMode, + ConnectionTrustRequest, + ClientRequest, + HostConnectionHandle, + HostConnectionFetch, + HostConnectionRpc, + RpcMessage, + ServerResponse, +} from "./rpc.ts"; +export type { PeerId, PeerScope, RemoteInvocation } from "@deepseek-ai/dsh-typert-protocol"; +export { RpcId, transportError } from "./rpc.ts"; +export { OperatorPeer } from "./operator-peer.ts"; +export { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from "./rpc-schema.ts"; +export { HostConnectionService } from "./rpc-host.ts"; + +export { API_PATH } from "./api-path.ts"; + +/** Stable Cordis plugin name. */ +export const name = "client-connection"; + +declare module "@deepseek-ai/cordis" { + interface Events { + /** + * Admit or wrap an authenticated shared API request, including body transfer. + * Existing requests continue when a listener refuses subsequent requests. + * @param request - Authenticated incoming HTTP request. + * @param response - Response owned until the delegated bridge settles. + * @param next - Delegate to the next listener or the shared API bridge. + * @mode waterfall + */ + "connection/request"( + request: IncomingMessage, + response: ServerResponse, + next: () => Promise, + ): Promise; + } +} + +/** Headroom for RPC JSON fields around aggregate base64 image payloads. */ +const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024; + +function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void { + const attachments = ctx.get("attachments"); + if (attachments === undefined) return; + const requiredImageBodyBytes = + Math.ceil((attachments.imageLimits.maxMessageImageBytes * 4) / 3) + + REQUEST_ENVELOPE_HEADROOM_BYTES; + if (maxRequestBodyBytes < requiredImageBodyBytes) { + throw new Error( + `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ` + + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`, + ); + } +} + +/** Services required before providing Connection. */ +export const inject = ["credentials"]; + +/** Browser authentication, request limits, and connection recovery configuration. */ +export interface ConnectionConfig { + /** Browser recovery timing, injected into each served page. */ + recovery?: ConnectionRecoveryConfig; + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. + */ + trustedHosts?: string[]; + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number; + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ + maxRequestBodyBytes?: number; +} + +export const Config: z = z.object({ + recovery: ConnectionRecoveryConfigSchema.default({}), + trustedHosts: z.array(String).default([]), + cookieMaxAgeDays: z.natural().min(1).default(30), + maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), +}); + +/** + * Provides carrier-neutral RPC and Fetch registries. When `webServer` is + * present, the plugin also mounts the `/api` browser transport with Host/Origin + * checks and persistent browser authentication. + * @param ctx - Host plugin context. + * @param config - resolved plugin config (schema defaults applied). + */ +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { + const recovery = resolveConnectionConfig(config?.recovery); + // The Loader resolves schema defaults; hand-built test contexts may pass none. + const trustedHosts = config?.trustedHosts ?? []; + const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30; + const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES; + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry); + assertImageBodyCapacity(ctx, maxRequestBodyBytes); + const connection = new HostConnectionService( + ctx, + trustedHosts, + await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays), + ); + ctx.inject(["webServer"], (webCtx) => { + assertImageBodyCapacity(webCtx, maxRequestBodyBytes); + webCtx.on("webserver/index-inject", (table) => { + table.push({ kind: "global", name: "__DSH_CONNECTION_RECOVERY__", value: recovery }); + }); + const fetchHandler = connection.createSharedFetchHandler(API_PATH); + const route: WebRoute = { + kind: "prefix", + path: API_PATH, + handler: async (req, res) => { + const admission = connection.admit(req); + if ("rejection" in admission) { + res.writeHead(admission.rejection); + res.end(admission.rejection === 401 ? "unauthorized" : "forbidden"); + return; + } + await webCtx.waterfall("connection/request", req, res, () => + bridge(req, res, fetchHandler, maxRequestBodyBytes), + ); + }, + }; + webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route"); + }); + ctx.inject(["attachments"], (attachmentCtx) => { + assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes); + }); +} diff --git a/apps/web-ui/packages/client/connection/src/loopback-hostname.ts b/apps/web-ui/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 000000000..da0c90760 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,20 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. + */ + +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === "localhost" || hostname === "[::1]") return true; + const parts = hostname.split("."); + return ( + parts.length === 4 && + parts[0] === "127" && + parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) + ); +} diff --git a/apps/web-ui/packages/client/connection/src/operator-peer.ts b/apps/web-ui/packages/client/connection/src/operator-peer.ts new file mode 100644 index 000000000..d931ddf26 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/operator-peer.ts @@ -0,0 +1,33 @@ +/** + * The operator Peer: the one party this Host answers to. Connection owns it + * for its own lifetime, admits every request as it, and hands it to each + * Remote call as `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/operator-peer + */ + +import { randomUUID } from "node:crypto"; +import type { Context } from "@deepseek-ai/cordis"; +import { createScope, type Scope } from "@deepseek-ai/dsh-scope"; +import type { PeerId, PeerScope } from "@deepseek-ai/dsh-typert-protocol"; + +/** + * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class OperatorPeer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId; + readonly ctx: Context; + private readonly scope: Scope; + + /** @param owner - Connection plugin context the scope fiber hangs under. */ + constructor(owner: Context) { + this.scope = createScope(owner, this); + this.ctx = this.scope.ctx; + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose(); + } +} diff --git a/apps/web-ui/packages/client/connection/src/recovery-config.ts b/apps/web-ui/packages/client/connection/src/recovery-config.ts new file mode 100644 index 000000000..b22a8762c --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/recovery-config.ts @@ -0,0 +1,46 @@ +/** Shared validation for Host-configured and browser-local connection recovery. */ +import z from "@deepseek-ai/schemastery"; + +/** Timing for generation readiness and automatic reconnection. */ +export interface ConnectionRecoveryConfig { + /** First-retry delay cap in ms; actual delay is 50–100% of the cap. Default: 500. */ + backoffBaseMs?: number; + /** Finite growth factor of at least 1 per failed attempt; 1 keeps a fixed cap. Default: 2. */ + backoffFactor?: number; + /** Maximum retry delay cap in ms; retries continue at this cap. Default: 10000. */ + backoffMaxMs?: number; + /** + * Delay before reporting a slow handshake, without cancelling it. Default: 3000. + * Omitted when readiness, failure, cancellation, or the hard deadline occurs first. + */ + generationReadyWarnMs?: number; + /** Deadline in ms for readiness, including physical connection setup. Default: 15000. */ + generationReadyTimeoutMs?: number; +} + +// Browsers and Node share this maximum signed 32-bit timer delay. +const MAX_TIMER_MS = 2_147_483_647; + +/** Schema shared by the Host plugin and the Client's recovery input parser. */ +export const ConnectionRecoveryConfigSchema: z = z.object({ + backoffBaseMs: z.natural().min(1).max(MAX_TIMER_MS).default(500), + backoffFactor: z.number().min(1).max(Number.MAX_VALUE).default(2), + backoffMaxMs: z.natural().min(1).max(MAX_TIMER_MS).default(10_000), + generationReadyWarnMs: z.natural().min(1).max(MAX_TIMER_MS).default(3_000), + generationReadyTimeoutMs: z.natural().min(1).max(MAX_TIMER_MS).default(15_000), +}); + +/** + * Validate recovery input and supply every timing default before starting work. + * @param config - Host configuration, page bootstrap data, or direct loop options. + * @returns validated, complete recovery timing. + */ +export function resolveConnectionConfig(config: unknown = {}): Required { + const resolved = ConnectionRecoveryConfigSchema( + config as ConnectionRecoveryConfig, + ) as Required; + if (!Number.isFinite(resolved.backoffFactor)) { + throw new RangeError("connection recovery backoffFactor must be finite"); + } + return resolved; +} diff --git a/apps/web-ui/packages/client/connection/src/rpc-host.ts b/apps/web-ui/packages/client/connection/src/rpc-host.ts new file mode 100644 index 000000000..2b68a7dac --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc-host.ts @@ -0,0 +1,340 @@ +/** Host registry and HTTP adapter for generic Connection RPC channels. */ + +import { Context, Service } from "@deepseek-ai/cordis"; +import type { WebRoute } from "@deepseek-ai/dsh-host-webserver"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; +import { RpcId, type ClientRequest, type RpcId as RpcIdType } from "./rpc.ts"; +import { clientRequestSchema } from "./rpc-schema.ts"; +import { bridge } from "./http-bridge.ts"; +import { isTrustedApiRequest } from "./api-request-trust.ts"; +import { API_PATH } from "./api-path.ts"; +import type { BrowserAuth } from "./browser-auth.ts"; +import { OperatorPeer } from "./operator-peer.ts"; +import type { + PeerAdmission, + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionFetchRoute, + ConnectionFetchHandler, + HostConnectionFetch, + ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, + ConnectionRpcHandler, + ConnectionRpcResult, + ConnectionRequestRejection, + ConnectionTrustRequest, + HostConnectionHandle, + HostConnectionRpc, +} from "./rpc.ts"; + +const INVALID_REQUEST_RPC_ID = RpcId("invalid-request"); +const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/; +const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/; + +interface ConnectionRpcInterceptor { + readonly matches: ConnectionRpcEndpointMatcher; + readonly fetchHandler: ConnectionFetchHandler; +} + +interface RegisteredFetchRoute { + readonly methods: ReadonlySet; + readonly requestBody: ConnectionFetchRoute["requestBody"]; + readonly fetch: ConnectionFetchRoute["fetch"]; +} + +interface ConnectionServerResponse { + readonly type: "server-response"; + readonly rpcId: RpcIdType; + readonly result: ConnectionRpcResult; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host Connection transport and RPC registrations. */ + connection: HostConnectionHandle; + } +} + +/** Host Connection service whose channel registrations belong to the caller fiber. */ +export class HostConnectionService extends Service implements HostConnectionHandle { + /** The operator Peer every admitted request speaks for. */ + readonly operator: PeerScope; + private readonly interceptors = new Map(); + private readonly fetchRoutes = new Map(); + + /** + * Provide the Host half over the active HTTP server. + * @param ctx - owning Connection plugin context. + * @param trustedHosts - deployment authorities accepted by the Host/Origin fence. + * @param browserAuth - process token and persistent browser-session owner. + */ + constructor( + ctx: Context, + private readonly trustedHosts: readonly string[], + private readonly browserAuth: BrowserAuth, + ) { + super(ctx, "connection"); + this.operator = new OperatorPeer(ctx); + ctx.effect(() => () => this.operator.dispose(), "client-connection: operator Peer"); + } + + /** Generic channel registry scoped to the Context reading this service. */ + get rpc(): HostConnectionRpc { + const owner = this.ctx; + return { + handle: (channel, handler) => this.register(owner, channel, handler), + intercept: (channel, matches, handler) => + this.registerInterceptor(owner, channel, matches, handler), + }; + } + + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx; + return { + register: (route) => this.registerFetchRoute(owner, route), + }; + } + + /** Apply the configured Host/Origin fence, then browser authentication. */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { + if (!isTrustedApiRequest(request, this.trustedHosts)) return 403; + return this.browserAuth.isAuthenticated(request) ? undefined : 401; + } + + /** A request that passes the fence and authentication speaks for the operator. */ + admit(request: ConnectionTrustRequest): PeerAdmission { + const rejection = this.requestRejection(request); + return rejection === undefined ? { peer: this.operator } : { rejection }; + } + + /** Authenticate an index request through the process-token exchange or cookie. */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { + return this.browserAuth.authorizeIndex(request, response); + } + + /** Add this process's launch token to the clean application URL. */ + authenticatedUrl(baseUrl: string): string { + return this.browserAuth.authenticatedUrl(baseUrl); + } + + /** + * Compose one shared-channel Fetch handler from exact routes and its interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler that selects one owner or returns 404. + */ + createSharedFetchHandler(channel: "/api"): ConnectionFetchHandler { + return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname); + return route?.methods.has(method) === true ? route.requestBody : "buffered"; + }, + fetch: (request) => { + const pathname = new URL(request.url).pathname; + const route = this.fetchRoutes.get(pathname); + if (route?.methods.has(request.method) === true) return route.fetch(request); + const endpoint = endpointFromPath(channel, pathname); + const interceptor = this.interceptors.get(channel); + if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { + return Promise.resolve(new Response("not found", { status: 404 })); + } + return interceptor.fetchHandler.fetch(request); + }, + }; + } + + private registerFetchRoute(owner: Context, route: ConnectionFetchRoute): () => Promise { + assertFetchRoute(route); + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + requestBody: route.requestBody, + fetch: route.fetch, + }; + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error( + `connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`, + ); + } + this.fetchRoutes.set(route.path, registered); + return () => { + this.fetchRoutes.delete(route.path); + }; + }, `client-connection: ${route.path} Fetch route`); + } + + private register( + owner: Context, + channel: string, + handler: ConnectionRpcHandler, + ): () => Promise { + assertChannel(channel); + const fetchHandler = rpcFetchHandler(channel, handler, this.operator); + const route: WebRoute = { + kind: "prefix", + path: channel, + handler: async (req, res) => { + const admission = this.admit(req); + if ("rejection" in admission) { + res.writeHead(admission.rejection); + res.end(admission.rejection === 401 ? "unauthorized" : "forbidden"); + return; + } + await bridge(req, res, fetchHandler); + }, + }; + return owner.effect( + () => owner.webServer.register(route), + `client-connection: ${channel} rpc channel`, + ); + } + + private registerInterceptor( + owner: Context, + channel: string, + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + ): () => Promise { + if (channel !== API_PATH) { + throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`); + } + const interceptor: ConnectionRpcInterceptor = { + matches, + fetchHandler: rpcFetchHandler(channel, handler, this.operator), + }; + return owner.effect(() => { + if (this.interceptors.has(channel)) { + throw new Error( + `connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`, + ); + } + this.interceptors.set(channel, interceptor); + return () => { + this.interceptors.delete(channel); + }; + }, `client-connection: ${channel} rpc interceptor`); + } +} + +function rpcFetchHandler( + channel: string, + handler: ConnectionRpcHandler, + peer: PeerScope, +): ConnectionFetchHandler { + return { + requestBodyMode: () => "buffered", + async fetch(request: Request): Promise { + const endpoint = endpointFromPath(channel, new URL(request.url).pathname); + if (request.method !== "POST" || endpoint === undefined) { + return new Response("not found", { status: 404 }); + } + + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") { + return new Response("content type must be application/json", { status: 415 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response("body is not JSON", { status: 400 }); + } + + const envelope = clientRequestSchema.safeParse(body); + if (!envelope.success) { + return invalidEnvelopeResponse(body, envelope.error.issues); + } + const message: ClientRequest = envelope.data; + if (message.method !== endpoint) { + return errorResponse(message.rpcId, { + code: "gateway/bad-request", + message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, + details: { issues: [] }, + }); + } + + try { + const result = await handler(endpoint, message.payload, request.signal, peer); + return fullResponse(message.rpcId, result); + } catch (error) { + return new Response(`handler failure: ${String(error)}`, { status: 500 }); + } + }, + }; +} + +function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response { + const rawId = (body as { rpcId?: unknown } | null)?.rpcId; + const rpcId = typeof rawId === "string" ? RpcId(rawId) : INVALID_REQUEST_RPC_ID; + return errorResponse(rpcId, { + code: "gateway/bad-request", + message: "invalid client-request message", + details: { issues }, + }); +} + +function endpointFromPath(channel: string, pathname: string): string | undefined { + if (!pathname.startsWith(`${channel}/`)) return undefined; + const endpoint = pathname.slice(channel.length + 1); + const segments = endpoint.split("/"); + if ( + segments.some( + (segment) => + segment === "" || + segment === "." || + segment === ".." || + !ENDPOINT_SEGMENT_PATTERN.test(segment), + ) + ) { + return undefined; + } + return endpoint; +} + +function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response { + return fullResponse(rpcId, { ok: false, error }); +} + +function fullResponse( + rpcId: RpcIdType, + result: Awaited>, +): Response { + if (!result.ok) { + const body: ConnectionServerResponse = { type: "server-response", rpcId, result }; + return Response.json(body); + } + const { attachments, ...success } = result; + const body: ConnectionServerResponse = { type: "server-response", rpcId, result: success }; + if (attachments === undefined || attachments.length === 0) return Response.json(body); + const parts = new FormData(); + const attachmentMetadata = attachments.map((attachment, index) => { + const part = `bytes-${index}`; + // FileSystem bytes may have SharedArrayBuffer backing, which BlobPart excludes. + parts.set(part, new Blob([new Uint8Array(attachment.bytes)])); + return { path: [...attachment.path], codec: "bytes" as const, part }; + }); + parts.set("metadata", JSON.stringify({ ...body, attachments: attachmentMetadata })); + return new Response(parts); +} + +function assertChannel(channel: string): void { + if (!CHANNEL_PATTERN.test(channel) || channel === "/api") { + throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`); + } +} + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`); + } + if (route.methods.length === 0) { + throw new Error( + `connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`, + ); + } + const methods = new Set(route.methods); + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`); + } +} diff --git a/apps/web-ui/packages/client/connection/src/rpc-schema.ts b/apps/web-ui/packages/client/connection/src/rpc-schema.ts new file mode 100644 index 000000000..d513ba687 --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc-schema.ts @@ -0,0 +1,56 @@ +/** Runtime validation for Connection RPC envelopes. */ + +import { z } from "zod"; +import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from "./rpc.ts"; + +/** Correlation id after wire validation. */ +export const rpcIdSchema = z.string() as unknown as z.ZodType; + +/** Generic endpoint failure carried in a response envelope. */ +export const rpcErrorSchema = z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()), +}); + +/** + * Build the result parser for one endpoint value parser. + * @param value - endpoint-owned success-value parser. + * @returns parser for either a success value or generic failure. + */ +export function rpcResultSchema(value: z.ZodType): z.ZodType< + | { + readonly ok: true; + readonly value: T; + } + | { + readonly ok: false; + readonly error: z.infer; + } +> { + return z.union([ + z.object({ ok: z.literal(true), value }), + z.object({ ok: z.literal(false), error: rpcErrorSchema }), + ]); +} + +/** Client request envelope; endpoint payload validation belongs to its owner. */ +export const clientRequestSchema = z.object({ + type: z.literal("client-request"), + rpcId: rpcIdSchema, + method: z.string(), + payload: z.unknown(), +}) as z.ZodType; + +/** Server response envelope; endpoint value validation belongs to its caller. */ +export const serverResponseSchema = z.object({ + type: z.literal("server-response"), + rpcId: rpcIdSchema, + result: rpcResultSchema(z.unknown().optional()), +}) as z.ZodType; + +/** Either Connection RPC envelope direction. */ +export const rpcMessageSchema = z.discriminatedUnion("type", [ + clientRequestSchema as unknown as z.ZodObject, + serverResponseSchema as unknown as z.ZodObject, +]) as unknown as z.ZodType; diff --git a/apps/web-ui/packages/client/connection/src/rpc.ts b/apps/web-ui/packages/client/connection/src/rpc.ts new file mode 100644 index 000000000..ff0e6884d --- /dev/null +++ b/apps/web-ui/packages/client/connection/src/rpc.ts @@ -0,0 +1,289 @@ +/** Generic unary RPC contracts shared by the Host and Client Connection halves. */ + +import type { Branded } from "@deepseek-ai/dsh-brand"; +import type { PeerScope } from "@deepseek-ai/dsh-typert-protocol"; + +/** Correlation id minted by a caller and echoed by the Connection response. */ +export type RpcId = Branded<"rpc-id">; + +/** + * Brand one validated string as a Connection correlation id. + * @param id - validated wire identity. + * @returns the same string with the correlation-id brand. + */ +export function RpcId(id: string): RpcId { + return id as RpcId; +} + +/** Carrier-neutral failure returned by one logical RPC endpoint. */ +export interface ConnectionRpcFailure { + readonly code: string; + readonly message: string; + readonly details: object; +} + +/** Carrier-neutral result returned by one logical RPC endpoint. */ +export type ConnectionRpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ConnectionRpcFailure }; + +/** One binary value separated from a successful RPC result before transport framing. */ +export interface ConnectionRpcAttachment { + /** Result-relative path occupied by the attachment's `null` placeholder. */ + readonly path: readonly (string | number)[]; + /** Byte view carried outside the JSON response metadata. */ + readonly bytes: Uint8Array; +} + +/** Successful or failed handler result ready for Connection transport framing. */ +export type ConnectionRpcHandlerResult = + | { + readonly ok: true; + readonly value: unknown; + /** Binary fields already projected by the handler that owns the result protocol. */ + readonly attachments?: readonly ConnectionRpcAttachment[]; + } + | { readonly ok: false; readonly error: ConnectionRpcFailure }; + +/** Historical short name for a generic Connection result. */ +export type RpcResult = ConnectionRpcResult; + +/** + * Convert a rejected transport operation into a generic failure result. + * @param error - rejected transport value. + * @returns an `internal` failure preserving the available message. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; +} + +/** Narrow request form used by direct fixture adapters. */ +export interface RpcRequest

{ + readonly rpcId: RpcId; + readonly payload: P; +} + +/** Narrow response form used by direct fixture adapters. */ +export interface RpcResponse { + readonly rpcId: RpcId; + readonly result: RpcResult; +} + +/** Full request envelope carried by Connection RPC transports. */ +export interface ClientRequest { + readonly type: "client-request"; + readonly rpcId: RpcId; + readonly method: string; + readonly payload: unknown; +} + +/** Full response envelope carried by Connection RPC transports. */ +export interface ServerResponse { + readonly type: "server-response"; + readonly rpcId: RpcId; + readonly result: ConnectionRpcResult; +} + +/** Complete Connection RPC envelope union. */ +export type RpcMessage = ClientRequest | ServerResponse; + +/** HTTP request facts consumed by browser trust and authentication. */ +export interface ConnectionTrustRequest { + /** Request headers supplied by either the Fetch or node:http representation. */ + readonly headers: Headers | Readonly>; +} + +/** HTTP status returned before dispatch, or undefined when the request may proceed. */ +export type ConnectionRequestRejection = 401 | 403 | undefined; + +/** Root/index request facts used by the browser-token exchange. */ +export interface ConnectionIndexRequest extends ConnectionTrustRequest { + readonly method?: string | undefined; + readonly url?: string | undefined; +} + +/** Root/index response operations owned by the browser-token exchange. */ +export interface ConnectionIndexResponse { + writeHead(status: number, headers?: Readonly>): unknown; + end(body?: string): unknown; +} + +/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +export type PeerAdmission = { readonly peer: PeerScope } | { readonly rejection: 401 | 403 }; + +/** + * Handler invoked after Connection has decoded the transport envelope. + * `peer` is the Peer the request was admitted as: the operator. + */ +export type ConnectionRpcHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, +) => Promise; + +/** Synchronous ownership test for one endpoint on a shared RPC channel. */ +export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean; + +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = "GET" | "HEAD" | "POST"; + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = "buffered" | "streaming"; + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string; + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[]; + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode; + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise; +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise; +} + +/** Host registry for logical RPC channels carried by the current transport. */ +export interface HostConnectionRpc { + /** + * Register one authenticated absolute channel prefix. + * @param channel - absolute logical channel such as `/rpc`. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @returns asynchronous disposer removing the channel and its physical route. + */ + handle(channel: string, handler: ConnectionRpcHandler): () => Promise; + + /** + * Intercept owned endpoints on the shared `/api` channel before its fallback. + * @param channel - reserved shared channel; currently `/api`. + * @param matches - synchronous endpoint ownership test. + * @param handler - decoded endpoint handler returning the existing RPC result shape. + * @returns asynchronous disposer removing the interceptor. + */ + intercept( + channel: "/api", + matches: ConnectionRpcEndpointMatcher, + handler: ConnectionRpcHandler, + ): () => Promise; +} + +/** Host `ctx.connection` members consumed by transport-independent adapters. */ +export interface HostConnectionHandle { + /** Generic RPC channel registry. */ + readonly rpc: HostConnectionRpc; + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch; + /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ + readonly operator: PeerScope; + + /** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ + createSharedFetchHandler(channel: "/api"): ConnectionFetchHandler; + + /** + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @returns rejection status, or undefined when the route may accept the request. + */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection; + + /** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ + admit(request: ConnectionTrustRequest): PeerAdmission; + + /** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean; + + /** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean application URL whose authority and mount are preserved. + * @returns tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}. + */ + authenticatedUrl(baseUrl: string): string; +} + +/** Transport-independent Fetch handler used by HTTP and worker carriers. */ +export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { + readonly method: string; + readonly url: URL; + }): ConnectionRequestBodyMode; + + /** + * Dispatch one already-authenticated request. + * @param request - Fetch request below the shared channel. + * @returns the registered response or a 404 response. + */ + fetch(request: Request): Promise; +} + +/** Client caller for logical RPC channels carried by the current transport. */ +export interface ClientConnectionRpc { + /** + * Call one endpoint through an already registered logical channel. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `goals/create`. + * @param payload - channel-owned request payload. + * @param signal - optional caller cancellation. + * @returns the endpoint-owned success/error result; correlation stays inside Connection. + */ + call( + channel: string, + endpoint: string, + payload: unknown, + signal?: AbortSignal, + ): Promise>; + + /** + * Open an in-process logical stream when the selected carrier supplies one. + * Browser transports omit this method; API Gateway owns their WebSocket mux. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `session/follow`. + * @param payload - channel-owned request payload. + * @param signal - caller cancellation for this logical stream. + * @param uplink - Client uplink items the Host method reads through `invocation.uplink()`. + * @returns decoded stream values from the in-process carrier. + */ + readonly open?: ( + channel: string, + endpoint: string, + payload: unknown, + signal: AbortSignal, + uplink?: AsyncIterable, + ) => AsyncIterable; +} diff --git a/apps/web-ui/packages/client/connection/tsdown.config.ts b/apps/web-ui/packages/client/connection/tsdown.config.ts new file mode 100644 index 000000000..8ad2f6680 --- /dev/null +++ b/apps/web-ui/packages/client/connection/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-connection", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/file-upload/generated/remote.d.ts b/apps/web-ui/packages/client/file-upload/generated/remote.d.ts new file mode 100644 index 000000000..347caa7b6 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/generated/remote.d.ts @@ -0,0 +1,26 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import type { + RemoteResult, + TypertRemoteContribution, +} from '@deepseek-ai/dsh-typert-protocol' +import type { EncodedFileUploadRequest, FileUploadValue } from '@deepseek-ai/dsh-client-file-upload/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteNamespace$66696c6555706c6f616473 { + upload: (agentId: SessionId, request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteMap { + 'fileUploads/upload': (agentId: SessionId, request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } + interface TypertRemoteNamespaceMap { + 'fileUploads': TypertRemoteNamespace$66696c6555706c6f616473 + } + interface TypertRemoteScopeMap { + 'agent:fileUploads/upload': (request: EncodedFileUploadRequest, signal?: AbortSignal) => Promise> + } +} + +export declare const TYPERT_REMOTE: TypertRemoteContribution +export default TYPERT_REMOTE +//# sourceMappingURL=typert.remote-client.d.ts.map diff --git a/apps/web-ui/packages/client/file-upload/generated/remote.js b/apps/web-ui/packages/client/file-upload/generated/remote.js new file mode 100644 index 000000000..e4aac0171 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/generated/remote.js @@ -0,0 +1,68 @@ +/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */ +import { z } from 'zod' + +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown())) +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema$value ??= z.object({ + 'data': z.string().readonly(), + 'name': z.string().readonly().optional(), +})) +let _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema$value +const _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema = () => (_deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema$value ??= z.object({ + 'receiptId': z.intersection(z.string(), z.unknown()).readonly(), + 'file': z.object({ + 'attachmentId': z.intersection(z.string(), z.unknown()), + 'name': z.string(), + 'bytes': z.number(), +}).readonly(), +})) + +export const TYPERT_REMOTE = { + package: '@deepseek-ai/dsh-client-file-upload', + descriptors: [ + { + id: '@deepseek-ai/dsh-client-file-upload#fileUploads/upload', + service: 'fileUploads', + namespace: 'fileUploads', + method: 'upload', + invocation: { kind: 'direct' }, + scope: { + context: 'agent', + wire: 'agentId', + }, + parameters: [ + { + name: 'agent', + wire: 'agentId', + source: 'lookup', + lookup: 'agent', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_0$schema, + }, + }, + { + name: 'request', + wire: 'request', + source: 'json', + codec: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-client-file-upload/types#EncodedFileUploadRequest', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_parameter_1$schema, + }, + }, + ], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', + typeSymbol: '@deepseek-ai/dsh-client-file-upload/types#FileUploadValue', + create: _deepseek_ai_dsh_client_file_upload_fileUploads_upload_result$schema, + }, + sourceLocation: {"file":"packages/client/file-upload/src/index.ts","line":105,"column":3}, + }, + ], +} + +export default TYPERT_REMOTE diff --git a/apps/web-ui/packages/client/file-upload/package.json b/apps/web-ui/packages/client/file-upload/package.json new file mode 100644 index 000000000..369da3452 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-client-file-upload", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./typert": { + "types": "./src/typert.host.ts", + "default": "./src/typert.host.ts" + }, + "./remote": { + "types": "./generated/remote.d.ts", + "default": "./generated/remote.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "zod": "^4.4.3", + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-util-crypto": "*", + "@deepseek-ai/dsh-typert-protocol": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/contract.ts b/apps/web-ui/packages/client/file-upload/src/client/contract.ts new file mode 100644 index 000000000..86c257ae6 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/contract.ts @@ -0,0 +1,33 @@ +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { FileUploadValue } from "../types.ts"; + +/** Browser request body accepted by the background file-upload service. */ +export type FileUploadBody = Blob | ReadableStream; + +/** Monotone byte progress reported while a browser body is consumed. */ +export interface FileUploadProgress { + readonly loaded: number; + readonly total?: number; +} + +/** Browser upload service addressed by one Session identity. */ +export interface FileUploadService { + /** + * Store one file for a Session. Blob and stream bodies use + * the background carrier; exact bytes use Remote. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise>; +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/index.ts b/apps/web-ui/packages/client/file-upload/src/client/index.ts new file mode 100644 index 000000000..f035c1a76 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/index.ts @@ -0,0 +1,26 @@ +/** Browser background-upload Cordis service. */ + +import type { Context } from "@deepseek-ai/cordis"; +import { FileUploadRuntime } from "./runtime.ts"; +import type { FileUploadService } from "./contract.ts"; + +export type { FileUploadProgress, FileUploadService } from "./contract.ts"; +export type { FileUploadReceiptId, FileUploadValue } from "../types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Session-addressed browser service for staged file uploads. */ + fileUpload: FileUploadService; + } +} + +/** The upload service uses the generated Remote fallback. */ +export const inject = ["remote"]; + +/** + * Provide the browser background-upload service. + * @param ctx - Client plugin context. + */ +export function apply(ctx: Context): void { + ctx.plugin(FileUploadRuntime); +} diff --git a/apps/web-ui/packages/client/file-upload/src/client/runtime.ts b/apps/web-ui/packages/client/file-upload/src/client/runtime.ts new file mode 100644 index 000000000..e98ab7554 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/client/runtime.ts @@ -0,0 +1,369 @@ +/** Background browser upload implementation for Blob and byte-stream bodies. */ + +import { Service, type Context } from "@deepseek-ai/cordis"; +import { bytesToBase64 } from "@deepseek-ai/dsh-util-crypto"; +import { RemoteError } from "@deepseek-ai/dsh-typert-protocol"; +import type { RemoteResult } from "@deepseek-ai/dsh-typert-protocol"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { FILE_UPLOAD_ROUTE } from "../protocol.ts"; +import type { + ClientFileUploadHooks, + EncodedFileUploadRequest, + FileUploadFetch, + FileUploadValue, +} from "../types.ts"; +import type { FileUploadBody, FileUploadService } from "./contract.ts"; + +interface FileUploadRequest { + /** Document-relative app-owned upload route, query string included. */ + readonly path: string; + readonly body: FileUploadBody; + readonly headers?: Readonly>; + readonly signal?: AbortSignal; + readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void; +} + +interface FileUploadResponse { + readonly status: number; + readonly body: string; +} + +interface FileUploadRemoteContext extends Context { + readonly remote: { + readonly fileUploads: { + upload( + sessionId: SessionId, + request: EncodedFileUploadRequest, + signal?: AbortSignal, + ): Promise>; + }; + }; +} + +interface UploadWorkerStart { + readonly url: string; + readonly body: FileUploadBody; + readonly headers: Readonly>; +} + +type UploadWorkerOutput = + | { readonly kind: "progress"; readonly loaded: number; readonly total?: number } + | { readonly kind: "complete"; readonly status: number; readonly body: string } + | { readonly kind: "error"; readonly message: string }; + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: UploadWorkerOutput): void; +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null }; + status: number; + responseText: string; + withCredentials: boolean; + onload: ((event: ProgressEvent) => void) | null; + onerror: ((event: ProgressEvent) => void) | null; + open(method: string, url: string): void; + setRequestHeader(name: string, value: string): void; + send(body: Blob): void; +} + +type UploadWorkerFetch = ( + input: string, + init: RequestInit & { readonly duplex: "half" }, +) => Promise; + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for requests and progress messages. + * @param createXhr - XMLHttpRequest factory used for Blob progress. + * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies. + */ +export function fileUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), + doFetch: UploadWorkerFetch = (input, init) => fetch(input, init), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data; + if (request.body instanceof Blob) { + const xhr = createXhr(); + xhr.open("POST", request.url); + xhr.withCredentials = true; + for (const [name, value] of Object.entries(request.headers)) + xhr.setRequestHeader(name, value); + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: "progress", + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput); + }; + xhr.onload = () => { + scope.postMessage({ + kind: "complete", + status: xhr.status, + body: xhr.responseText, + } satisfies UploadWorkerOutput); + }; + xhr.onerror = () => { + scope.postMessage({ + kind: "error", + message: "background upload transport failed", + } satisfies UploadWorkerOutput); + }; + xhr.send(request.body); + return; + } + if (!(request.body instanceof ReadableStream)) { + scope.postMessage({ + kind: "error", + message: "background upload worker received an invalid body", + }); + return; + } + const source = request.body; + void (async () => { + const reader = source.getReader(); + let loaded = 0; + const body = new ReadableStream({ + async pull(controller) { + const item = await reader.read(); + if (item.done) { + controller.close(); + return; + } + if (!(item.value instanceof Uint8Array)) { + throw new TypeError("background upload stream produced a non-Uint8Array chunk"); + } + loaded += item.value.byteLength; + scope.postMessage({ kind: "progress", loaded }); + controller.enqueue(item.value); + }, + async cancel(reason) { + await reader.cancel(reason); + }, + }); + const response = await doFetch(request.url, { + method: "POST", + headers: request.headers, + credentials: "include", + body, + duplex: "half", + }); + scope.postMessage({ + kind: "complete", + status: response.status, + body: await response.text(), + }); + })().catch((error: unknown) => { + scope.postMessage({ + kind: "error", + message: error instanceof Error ? error.message : String(error), + }); + }); + }; +} + +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks; +} + +interface FileUploadTransport { + post(request: FileUploadRequest): Promise; +} + +/** Cordis service that owns one background carrier per upload operation. */ +export class FileUploadRuntime extends Service implements FileUploadService { + private readonly transport: FileUploadTransport; + + /** @param ctx - providing Client context. */ + constructor(ctx: Context) { + super(ctx, "fileUpload"); + const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__; + this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch); + } + + /** + * Post one body with the carrier selected before Cordis boot. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise { + return this.transport.post(request); + } + + /** + * Store one file for a Session. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + async upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + if (!(data instanceof Uint8Array)) { + const query = new URLSearchParams({ sessionId }); + if (name !== undefined) query.set("name", name); + const response = await this.post({ + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, + body: data, + headers: { "content-type": "application/octet-stream" }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }); + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`); + } + return parseFileUploadResult(response.body); + } + return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( + sessionId, + { + data: bytesToBase64(data), + ...(name === undefined ? {} : { name }), + }, + signal, + ); + } +} + +function customTransport(customFetch: FileUploadFetch): FileUploadTransport { + return { + async post(request) { + const init: RequestInit & { duplex?: "half" } = { + method: "POST", + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.body instanceof ReadableStream ? { duplex: "half" as const } : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }), + }; + const response = await customFetch(request.path, init); + return { status: response.status, body: await response.text() }; + }, + }; +} + +function workerTransport(): FileUploadTransport { + return { + post(request) { + if (typeof Worker !== "function") { + return Promise.reject(new Error("background upload requires Web Worker support")); + } + const workerUrl = URL.createObjectURL( + new Blob([`(${fileUploadWorker.toString()})()`], { type: "text/javascript" }), + ); + const worker = new Worker(workerUrl, { name: "dsh-file-upload" }); + URL.revokeObjectURL(workerUrl); + return new Promise((resolve, reject) => { + let settled = false; + const abort = (): void => { + settled = true; + worker.terminate(); + request.signal?.removeEventListener("abort", abort); + reject(new DOMException("The operation was aborted.", "AbortError")); + }; + const finish = (settle: () => void): void => { + if (settled) return; + settled = true; + request.signal?.removeEventListener("abort", abort); + worker.terminate(); + settle(); + }; + worker.onmessage = (event: MessageEvent) => { + const output = event.data; + if (output.kind === "progress") { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }); + } else if (output.kind === "complete") { + finish(() => { + resolve({ status: output.status, body: output.body }); + }); + } else { + finish(() => { + reject(new Error(output.message)); + }); + } + }; + worker.onerror = (event) => { + finish(() => { + reject(new Error(event.message || "background upload worker failed")); + }); + }; + if (request.signal?.aborted === true) { + abort(); + return; + } + request.signal?.addEventListener("abort", abort, { once: true }); + const message: UploadWorkerStart = { + // The Worker's own base is `blob:`, so its request URL must be absolute. + url: new URL(request.path, document.baseURI).href, + body: request.body, + headers: request.headers ?? {}, + }; + if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body]); + else worker.postMessage(message); + }); + }, + }; +} + +function parseFileUploadResult(body: string): RemoteResult { + const value: unknown = JSON.parse(body); + if (!isRecord(value) || typeof value.ok !== "boolean") { + throw new TypeError("file upload transport returned an invalid result"); + } + if (!value.ok) { + const error = value.error; + if ( + !isRecord(error) || + typeof error.code !== "string" || + typeof error.message !== "string" || + !isRecord(error.details) + ) { + throw new TypeError("file upload transport returned an invalid failure"); + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + }; + } + const result = value.value; + const file = isRecord(result) ? result.file : undefined; + if ( + !isRecord(result) || + typeof result.receiptId !== "string" || + !isRecord(file) || + typeof file.attachmentId !== "string" || + typeof file.name !== "string" || + typeof file.bytes !== "number" || + !Number.isSafeInteger(file.bytes) || + file.bytes < 0 + ) { + throw new TypeError("file upload transport returned an invalid receipt"); + } + return { + ok: true, + value: { + receiptId: result.receiptId as FileUploadValue["receiptId"], + file: { + attachmentId: file.attachmentId as FileUploadValue["file"]["attachmentId"], + name: file.name, + bytes: file.bytes, + }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web-ui/packages/client/file-upload/src/http-route.ts b/apps/web-ui/packages/client/file-upload/src/http-route.ts new file mode 100644 index 000000000..cf672088e --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/http-route.ts @@ -0,0 +1,87 @@ +/** Authenticated raw-byte upload route registered on the Connection fetch registry. */ + +import { brandString } from "@deepseek-ai/dsh-brand"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import { remoteErrorOf } from "@deepseek-ai/dsh-typert-protocol"; +import type { FileUploads } from "./index.ts"; +import type { FileUploadValue } from "./types.ts"; + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false; + readonly error: { readonly code: string; readonly message: string; readonly details: object }; + }; + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp( + service: FileUploads, + request: Request, +): Promise { + if (request.method !== "POST") { + return new Response(null, { status: 405, headers: { allow: "POST" } }); + } + const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/octet-stream") { + return new Response("content type must be application/octet-stream", { status: 415 }); + } + const url = new URL(request.url); + const sessionId = url.searchParams.get("sessionId"); + if (sessionId === null || sessionId === "") { + return new Response("sessionId is required", { status: 400 }); + } + const name = url.searchParams.get("name") ?? undefined; + let result: FileUploadHttpResult; + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + }; + } catch (error) { + const failure = remoteErrorOf(error); + result = { + ok: false, + error: + failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: "gateway/internal", + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + }; + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +async function* requestBodyChunks( + body: ReadableStream | null, +): AsyncIterable { + if (body === null) return; + const reader = body.getReader(); + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) return; + yield chunk.value; + } + } finally { + reader.releaseLock(); + } +} diff --git a/apps/web-ui/packages/client/file-upload/src/index.ts b/apps/web-ui/packages/client/file-upload/src/index.ts new file mode 100644 index 000000000..29f029f41 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/index.ts @@ -0,0 +1,281 @@ +/** Host file-upload service: streamed intake and Session-owned staged receipts. */ + +import { randomUUID } from "node:crypto"; +import type { Context } from "@deepseek-ai/cordis"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment"; +import type {} from "@deepseek-ai/dsh-client-connection"; +import type { CommandFileReceiptResolver } from "@deepseek-ai/dsh-commands"; +import type { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session"; +import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; +import { handleFileUploadHttp } from "./http-route.ts"; +import { FILE_UPLOAD_PATH } from "./protocol.ts"; +import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from "./types.ts"; + +export type * from "./types.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Host storage and staged-receipt service for browser file uploads. */ + fileUploads: FileUploads; + } +} + +interface StagedFileUpload { + readonly file: FileAttachmentRef; + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: string; +} + +/** Resolve or resume the ordinary Agent that owns one Session identity. */ +export type AgentResolver = (sessionId: SessionId) => Promise; + +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void; +} + +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false; + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true; + } + + [Symbol.dispose](): void { + if (this.settled) return; + this.settled = true; + this.rollback(); + } +} + +/** Host service owning upload storage and receipts keyed by each receiving Agent's exact Session. */ +export class FileUploads extends TypertRemoteService { + static inject = ["agents", "attachments", "commands", "connection"]; + + private readonly stagedFiles = new WeakMap>(); + private agentResolver: AgentResolver | undefined; + + /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ + constructor(ctx: Context) { + super(ctx, "fileUploads"); + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolve(agent, receiptId as FileUploadReceiptId); + ctx.effect( + () => ctx.commands.registerFileReceiptResolver(resolve), + "file-upload: command file receipt resolver", + ); + ctx.effect( + () => + ctx.connection.fetch.register({ + path: FILE_UPLOAD_PATH, + methods: ["POST"], + requestBody: "streaming", + fetch: (request) => handleFileUploadHttp(this, request), + }), + "file-upload: streaming route", + ); + ctx.on("session/event", (session, event) => { + this.observeSessionEvent(session, event); + }); + ctx.on("session/disposed", (session) => { + this.stagedFiles.delete(session); + }); + } + + /** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ + registerAgentResolver(resolve: AgentResolver): () => void { + if (this.agentResolver !== undefined) + throw new Error("file-upload: Agent resolver is already registered"); + this.agentResolver = resolve; + return () => { + if (this.agentResolver === resolve) this.agentResolver = undefined; + }; + } + + /** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ + @Remote("upload") + upload( + agent: Agent, + request: EncodedFileUploadRequest, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + return this.commit(agent, async () => + this.ctx.attachments.admitEncodedFile({ + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + }), + ); + } + + /** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ + async uploadStream(request: { + readonly sessionId: SessionId; + readonly data: AsyncIterable; + readonly signal?: AbortSignal; + readonly name?: string; + }): Promise { + const agent = await this.resolveAgent(request.sessionId); + return this.commit(agent, async () => + this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + }), + ); + } + + /** + * Resolve one staged receipt for the receiving Agent's exact Session. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ + resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + return this.stagedFiles.get(agent.session)?.get(receiptId)?.file; + } + + /** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { + const staged = this.stagedFiles.get(agent.session); + const bound = receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId); + if (upload === undefined) throw fileNotStaged(); + return { upload, previous: upload.requestId }; + }); + for (const { upload } of bound) upload.requestId = requestId; + return new PromptFileBindingGuard(() => { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId; + else upload.requestId = previous; + } + }); + } + + /** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ + retirePrompt(agent: Agent, requestId: string): void { + this.retire(agent.session, requestId); + } + + private async commit( + agent: Agent, + save: () => Promise, + ): Promise { + this.assertOrdinaryAgent(agent); + let file: FileAttachmentRef; + try { + file = await save(); + } catch (error) { + if (this.ctx.attachments.isAttachmentError(error)) { + throw new RemoteError("session/attachment-invalid" as never, error.message, { + reason: error.code, + } as never); + } + throw new RemoteError( + "gateway/internal", + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ); + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + "session/not-found", + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ); + } + let staged = this.stagedFiles.get(agent.session); + if (staged === undefined) { + staged = new Map(); + this.stagedFiles.set(agent.session, staged); + } + const receiptId = randomUUID() as FileUploadReceiptId; + staged.set(receiptId, { file }); + return { receiptId, file }; + } + + private async resolveAgent(sessionId: SessionId): Promise { + const live = this.ctx.agents.get(sessionId); + if (live !== undefined) return live; + const resolver = this.agentResolver; + if (resolver === undefined) { + throw new RemoteError("session/not-found", `session "${sessionId}" is not attached`, { + sessionId, + }); + } + return resolver(sessionId); + } + + private assertOrdinaryAgent(agent: Agent): void { + if (agent.session.header.origin === "subagent") { + throw new RemoteError( + "subagent/attachment-invalid" as never, + "subagent conversations do not accept file uploads", + { reason: "SUBAGENT_FILE_UNSUPPORTED" } as never, + ); + } + } + + private observeSessionEvent(session: Session, event: SessionEvent): void { + if ( + event.type !== "user/message" || + event.data.source.kind !== "user" || + !("rpcId" in event.data.source) + ) + return; + if (typeof event.data.source.rpcId === "string") this.retire(session, event.data.source.rpcId); + } + + private retire(session: Session, requestId: string): void { + const staged = this.stagedFiles.get(session); + if (staged === undefined) return; + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId); + } + if (staged.size === 0) this.stagedFiles.delete(session); + } +} + +function fileNotStaged(): RemoteError { + return new RemoteError( + "session/attachment-invalid" as never, + "File was not uploaded for this session.", + { reason: "FILE_NOT_STAGED" } as never, + ); +} + +export default FileUploads; diff --git a/apps/web-ui/packages/client/file-upload/src/protocol.ts b/apps/web-ui/packages/client/file-upload/src/protocol.ts new file mode 100644 index 000000000..c84eebdc5 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/protocol.ts @@ -0,0 +1,8 @@ +/** Authenticated raw-byte route owned by the file-upload service. */ +export const FILE_UPLOAD_PATH = "/api/session/uploadFileBinary"; + +/** + * Browser-relative form of {@link FILE_UPLOAD_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const FILE_UPLOAD_ROUTE = FILE_UPLOAD_PATH.slice(1); diff --git a/apps/web-ui/packages/client/file-upload/src/types.ts b/apps/web-ui/packages/client/file-upload/src/types.ts new file mode 100644 index 000000000..573fa54a0 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/src/types.ts @@ -0,0 +1,36 @@ +/** Browser-safe request and receipt types for staged file uploads. */ + +import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment/types"; +import type { Branded } from "@deepseek-ai/dsh-brand"; + +/** Canonical encoded upload accepted by the Remote fallback. */ +export interface EncodedFileUploadRequest { + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string; + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string; +} + +/** Durable receipt for one staged file upload. */ +export interface FileUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId; + readonly file: FileAttachmentRef; +} + +/** Host-minted authority for one staged file upload in one Agent scope. */ +export type FileUploadReceiptId = Branded<"file-upload-receipt-id">; + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - document-relative upload route; a carrier that needs an + * absolute URL resolves it against its own base. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: string | URL, init: RequestInit) => Promise; + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch; +} diff --git a/apps/web-ui/packages/client/file-upload/tsdown.config.ts b/apps/web-ui/packages/client/file-upload/tsdown.config.ts new file mode 100644 index 000000000..733ecb264 --- /dev/null +++ b/apps/web-ui/packages/client/file-upload/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-file-upload", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/locale/package.json b/apps/web-ui/packages/client/locale/package.json new file mode 100644 index 000000000..6f7546cd0 --- /dev/null +++ b/apps/web-ui/packages/client/locale/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-client-locale", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": { + "@deepseek-ai/dsh-client-store": "*", + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-primitives": "*", + "@deepseek-ai/schemastery": "*" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css b/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 000000000..af02724b4 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,51 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Language selector uses the standard control size. */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx b/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 000000000..dd194c260 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,72 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from "react"; +import type { PropsLocale, PropsRuntime, PropsStore } from "@deepseek-ai/dsh-client-ui-slots"; +import { IconChevronDownOutlineRegular, Menu } from "@deepseek-ai/dsh-client-ui-primitives"; +import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; +import type { createLanguageRowStore } from "./settings-store.ts"; +import css from "./LanguageRow.module.css"; + +/** Injected business face: the preference write (t rides the standard locale seat). */ +export interface LanguageRowInjected { + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void; +} + +/** Full component props: runtime share + store share + locale seat + injected face. */ +export type LanguageRowComponentProps = PropsRuntime<"settings.general.item"> & + PropsStore> & + PropsLocale<"settings.locale"> & + LanguageRowInjected; + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore((s) => s.active); + const options = useStore((s) => s.options); + const [open, setOpen] = useState(false); + const activeLabel = options.find((o) => o.id === active)?.label ?? active; + + return ( +

+
+
{t("language.title")}
+
+ { + setOpen(false); + }} + items={options.map((o) => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id); + setOpen(false); + }} + align="end" + portal + anchor={ + + } + /> +
+ ); +} diff --git a/apps/web-ui/packages/client/locale/src/client/bootstrap.ts b/apps/web-ui/packages/client/locale/src/client/bootstrap.ts new file mode 100644 index 000000000..279ae75f0 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/bootstrap.ts @@ -0,0 +1,37 @@ +/** Locale initialization supplied by a native shell before its Client tree mounts. */ + +/** System languages and the existing Host preference for one page load. */ +export interface LocaleBootstrap { + /** Operating-system language tags in preference order. */ + readonly languages: readonly string[]; + /** Stored locale.preference; null preserves automatic language selection. */ + readonly preference: string | null; +} + +/** Optional page-global bridge named __DSH_LOCALE__; ordinary browsers omit it. */ +export interface LocaleBridge { + /** Read current initialization data through the shell's isolated preload. @returns unvalidated IPC data. */ + read(): Promise; + /** Publish the resolved Client locale without writing another preference. @param locale - active registered locale id. */ + onChange(locale: string): void; +} + +/** + * Validate initialization data returned over the preload IPC bridge. + * @param value - untrusted IPC response. + * @returns language initialization with no persistence side effects. + */ +export function parseLocaleBootstrap(value: unknown): LocaleBootstrap { + if ( + typeof value !== "object" || + value === null || + !("languages" in value) || + !Array.isArray(value.languages) || + !value.languages.every((language: unknown) => typeof language === "string") || + !("preference" in value) || + (value.preference !== null && typeof value.preference !== "string") + ) { + throw new TypeError("locale: invalid native initialization data"); + } + return { languages: value.languages, preference: value.preference }; +} diff --git a/apps/web-ui/packages/client/locale/src/client/index.ts b/apps/web-ui/packages/client/locale/src/client/index.ts new file mode 100644 index 000000000..aeff42d83 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/index.ts @@ -0,0 +1,668 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale feature owns + * its own settings surface. + */ +import type { Context as ClientContext } from "@deepseek-ai/cordis"; +import type { LocalizedText } from "@deepseek-ai/dsh-package-manifest"; +import { + type BoundActions, + type LocaleDictOf, + type LocaleNamespaceMap, + type Translate, + type TranslateNS, +} from "@deepseek-ai/dsh-client-ui-slots"; +// Type-only: the ctx.configForms Context merge and the settings slot types. +// Cross-plugin collaboration goes through the service, never a value import +// (client bundle purity gate). +import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; +import { parseLocaleBootstrap, type LocaleBootstrap, type LocaleBridge } from "./bootstrap.ts"; +// Type-only: pulls the SlotRegistry service merge (ctx.slots). +import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; +import { + LOCALE_ID_PATTERN, + LOCALE_IDS, + LOCALE_PREFERENCE_FIELD, + LOCALE_SETTINGS_NAMESPACE, + type BuiltInLocaleId, + type LocaleId, + type LocaleSettings, +} from "../locale-settings.ts"; +import { en, zh, type CommonKey } from "../locales/index.ts"; +import { en as settingsEn, zh as settingsZh, type SettingsLocaleKey } from "../locales/settings.ts"; +import type { LanguageRowInjected } from "./LanguageRow.tsx"; +import { LanguageRow } from "./LanguageRow.tsx"; +import { createLanguageRowStore } from "./settings-store.ts"; + +export type { LanguageRowComponentProps, LanguageRowInjected } from "./LanguageRow.tsx"; +export type { LanguageOptionRow, LanguageRowState } from "./settings-store.ts"; +export type { CommonKey } from "../locales/index.ts"; +export type { BuiltInLocaleId, LocaleId, LocaleSettings } from "../locale-settings.ts"; + +// The translate currency lives in ui-slots (the render machinery synthesizes +// the seat); re-exported here so dictionary owners import one package. +// TranslateNS<'model'> is the namespace-addressed developer-facing form. +export type { Translate, TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; + +declare module "@deepseek-ai/dsh-client-ui-slots" { + interface LocaleNamespaceMap { + /** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */ + common: CommonKey; + /** This feature's own settings-row copy (the Language row). */ + "settings.locale": SettingsLocaleKey; + } +} + +/** Locale dictionary: flat key to template string ({name} placeholders). */ +export type LocaleDict = Record; + +/** Input accepted when a language-pack plugin adds a selectable language. */ +export interface LanguageRegistration { + /** Stable BCP 47-style id stored as the locale preference. */ + id: LocaleId; + /** Display name written in the represented language. */ + label: string; + /** Registered language consulted when this language lacks a dictionary key. */ + fallback: LocaleId; +} + +/** One normalized selectable locale published in snapshots. */ +export interface LocaleDefinition { + /** Stable id persisted by {@link LocaleRuntime.setLocale}. */ + readonly id: LocaleId; + /** Display name written in the represented language. */ + readonly label: string; + /** Next language in the per-key fallback chain; absent only for English. */ + readonly fallback?: LocaleId; +} + +/** Immutable locale state published on every change. */ +export interface LocaleSnapshot { + /** Active locale id. */ + active: LocaleId; + /** Selectable locales in display order. */ + locales: readonly LocaleDefinition[]; + /** Monotonic change counter (registry or active changes). */ + revision: number; +} + +declare module "@deepseek-ai/cordis" { + interface Context { + locale: LocaleRuntime; + } + interface Events { + /** + * The active locale switched. Dictionary registrations do NOT emit this + * event (listeners may re-register slots in response, and boot registers + * one namespace per package); continuous render refresh rides the + * LocaleFace revision instead. + * @param snapshot - Current immutable locale snapshot. + * @mode emit + */ + "locale/change"(snapshot: LocaleSnapshot): void; + } +} + +/** + * English is both the locale the UI opens in when the browser names no registered + * language (and for non-browser runs), and the dictionary consulted after the + * active locale misses a key. One constant serves both because the shipped + * `zh`/`en` dictionaries carry identical key sets, so neither direction can + * leave a key unresolved; the residual case points at English rather than + * zh because a browser naming no registered language is the reader least + * likely to read Chinese. + */ +export const FALLBACK_LOCALE: BuiltInLocaleId = "en"; + +/** Shared namespace for shell-level texts. */ +export const COMMON_NS = "common"; + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = "settings.locale"; + +/** The two locales and dictionaries shipped by this package. */ +const BUILT_IN_LOCALE_METADATA = { + zh: { label: "中文", fallback: "en" }, + en: { label: "English" }, +} as const satisfies Record>; +const BUILT_IN_LOCALES: readonly LocaleDefinition[] = Object.freeze( + LOCALE_IDS.map((id) => Object.freeze({ id, ...BUILT_IN_LOCALE_METADATA[id] })), +); + +/** Case-insensitive key for BCP 47-style ids. */ +function localeKey(value: string): string { + return value.toLowerCase(); +} + +/** Validate and detach a language-pack contribution from its mutable input. */ +function normalizeLanguage(input: LanguageRegistration): Readonly { + if (!LOCALE_ID_PATTERN.test(input.id)) { + throw new Error(`locale id "${input.id}" is not a BCP 47-style tag`); + } + if (input.label.trim() === "") throw new Error("locale label must not be empty"); + if (!LOCALE_ID_PATTERN.test(input.fallback)) { + throw new Error(`locale fallback "${input.fallback}" is not a BCP 47-style tag`); + } + return Object.freeze({ id: input.id, label: input.label, fallback: input.fallback }); +} + +/** + * Point `` at the active locale, keeping the served document in + * sync with locale snapshot changes. + * @param snapshot - current locale state, including the active definition. + */ +function syncDocumentLanguage(snapshot: LocaleSnapshot): void { + // Non-browser runs (node boots of the client tree) have no document. + if (typeof document === "undefined") return; + const language = snapshot.active === "zh" ? "zh-CN" : snapshot.active; + if (document.documentElement.lang !== language) document.documentElement.lang = language; +} + +/** + * Dictionary registry plus locale preference. Lookup walks the active + * language's declared fallback chain in the entry namespace, then repeats it + * in the shared common namespace before showing the key itself. Reads go + * through {@link getLocale}; preferences change only through + * {@link setLocale}, while language packs extend the catalog through + * {@link addLanguage}. Continuous sync uses the `locale/change` event or + * the LocaleFace getSnapshot/subscribe pair installed through + * `ctx.slots.installLocale`. + */ +export class LocaleRuntime { + private dicts = new Map>(); + private bound = new Map(); + private catalog = new Map(); + private fallbackChains = new Map(); + private snapshot: LocaleSnapshot; + private listeners = new Set<() => void>(); + private readonly ctx: ClientContext; + private readonly host: ConfigForm | undefined; + /** Browser-derived locale standing wherever no explicit Host selection does. */ + private provisional: LocaleId; + /** Last explicit selection, including one awaiting an external registration. */ + private preference: LocaleId | undefined; + + /** + * @param ctx - owning context (change events are emitted on it; the scope + * listener is released through ctx.effect on dispose). + * @param host - durable preference scope owned by the providing plugin; + * absent compositions (standalone dictionary registries) stay process-local. + * @param bootstrap - native initialization; absent in ordinary browsers. + */ + constructor( + ctx: ClientContext, + host?: ConfigForm, + private readonly bootstrap?: LocaleBootstrap, + ) { + this.ctx = ctx; + this.host = host; + for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale); + const locales = this.localeList(); + this.provisional = resolveInitialLocale(locales, bootstrap?.languages); + this.preference = bootstrap?.preference ?? undefined; + this.snapshot = Object.freeze({ active: this.resolveActive(), locales, revision: 0 }); + if (host !== undefined) { + ctx.effect( + () => + host.subscribe(() => { + this.adopt(host); + }), + "locale: settings scope adoption", + ); + this.adopt(host); + } + } + + /** + * Read the current immutable locale snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getLocale(): LocaleSnapshot { + return this.snapshot; + } + + /** + * Resolve package text through the active language's declared fallback chain. + * Plain strings stay verbatim; maps do not consult registered dictionaries. + * @param text - package text whose locale keys are lowercase and include English. + * @returns the first available translation, including an empty string. + */ + resolveText(text: LocalizedText): string { + if (typeof text === "string") return text; + return this.fallbackChain(this.snapshot.active).reduceRight( + (resolved, locale) => text[localeKey(locale)] ?? resolved, + text.en, + ); + } + + /** + * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable + * reference between changes, uSES-safe). + * @returns the current snapshot. + */ + getSnapshot(): LocaleSnapshot { + return this.snapshot; + } + + /** + * LocaleFace subscribe: notified on every snapshot change (locale switch + * or dictionary registration — registrations bump the revision so already + * rendered outlets pick up late-arriving dictionaries and locale definitions). + * @param fn - change callback. + * @returns unsubscribe. + */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + } + + /** + * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. + * @param id - a registered locale id; unknown ids throw. + */ + setLocale(id: string): void { + const match = this.catalog.get(localeKey(id)); + if (match === undefined) throw new Error(`locale "${id}" is not registered`); + this.preference = match.id; + if (this.snapshot.active !== match.id) this.publish(match.id, true); + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id); + } + + /** + * Add one selectable language to the shared catalog. Its fallback must + * already be registered, and following fallback definitions must terminate + * at English. Dictionaries may register before or after this definition. + * Registration rechecks an unresolved Host preference and the browser's + * ordered language list. The caller owns the returned disposer; removing an + * active language falls back without clearing the stored id. + * @param input - stable id, self-described label, and fallback language id. + * @returns idempotent disposer removing this exact definition. + * @throws when fields are malformed, the id is occupied, or the fallback + * target is unknown or creates a cycle. + */ + addLanguage(input: LanguageRegistration): () => void { + const candidate = normalizeLanguage(input); + const key = localeKey(candidate.id); + if (this.catalog.has(key)) throw new Error(`locale "${candidate.id}" is already registered`); + const fallback = this.catalog.get(localeKey(candidate.fallback)); + if (fallback === undefined) { + throw new Error(`locale fallback "${candidate.fallback}" is not registered`); + } + const language = Object.freeze({ ...candidate, fallback: fallback.id }); + this.catalog.set(key, language); + try { + this.assertFallbackChain(language.id); + } catch (error) { + this.catalog.delete(key); + throw error; + } + this.publishCatalog(); + return () => { + if (this.catalog.get(key) !== language) return; + this.catalog.delete(key); + this.publishCatalog(); + }; + } + + /** + * Adopt the scope's accepted durable selection without writing it back; an + * absent selection returns to the browser-derived locale. + * @param host - the constructor-narrowed scope driving this adoption. + */ + private adopt(host: ConfigForm): void { + const section = host.getSnapshot().value; + if (section === undefined) return; + this.preference = section.preference; + const target = this.resolveActive(); + if (this.snapshot.active === target) return; + this.publish(target, true); + } + + /** Recompute browser fallback and publish the current catalog. */ + private publishCatalog(): void { + this.fallbackChains.clear(); + const locales = this.localeList(); + this.provisional = resolveInitialLocale(locales, this.bootstrap?.languages); + const active = this.resolveActive(); + this.publish(active, active !== this.snapshot.active, locales); + } + + /** Resolve an explicit preference only while its definition is available. */ + private resolveActive(): LocaleId { + if (this.preference === undefined) return this.provisional; + return this.catalog.get(localeKey(this.preference))?.id ?? this.provisional; + } + + /** Snapshot the catalog in registration order. */ + private localeList(): readonly LocaleDefinition[] { + return Object.freeze([...this.catalog.values()]); + } + + /** Fail a new definition whose complete fallback path does not reach English. */ + private assertFallbackChain(start: LocaleId): void { + const seen = new Set(); + let current = this.catalog.get(localeKey(start)); + while (current !== undefined) { + const key = localeKey(current.id); + if (seen.has(key)) throw new Error(`locale fallback cycle includes "${current.id}"`); + seen.add(key); + if (key === localeKey(FALLBACK_LOCALE)) return; + /* v8 ignore next -- English is the only built-in terminal and every + * language accepted by addLanguage has a required fallback. */ + if (current.fallback === undefined) { + throw new Error( + `locale "${current.id}" fallback chain does not reach "${FALLBACK_LOCALE}"`, + ); + } + const next = this.catalog.get(localeKey(current.fallback)); + if (next === undefined) { + throw new Error(`locale fallback "${current.fallback}" is not registered`); + } + current = next; + } + } + + /** Resolve a lookup chain, falling directly to English across an unload gap. */ + private fallbackChain(start: LocaleId): readonly LocaleId[] { + const startKey = localeKey(start); + const cached = this.fallbackChains.get(startKey); + if (cached !== undefined) return cached; + const chain: LocaleId[] = []; + const seen = new Set(); + let current = this.catalog.get(startKey); + while (current !== undefined && !seen.has(localeKey(current.id))) { + const key = localeKey(current.id); + seen.add(key); + chain.push(current.id); + current = + current.fallback === undefined ? undefined : this.catalog.get(localeKey(current.fallback)); + } + if (!seen.has(localeKey(FALLBACK_LOCALE))) chain.push(FALLBACK_LOCALE); + const resolved = Object.freeze(chain); + this.fallbackChains.set(startKey, resolved); + return resolved; + } + + /** + * Register a declared namespace's dictionaries, all locales in one call — + * the typed form: each dictionary is checked against the namespace's + * {@link LocaleNamespaceMap} key union (a missing or extra key is a + * compile error), and every shipped locale is required (bilingual balance + * enforced at registration). Duplicate (ns, locale) throws (single occupant; a + * namespace's texts have one owner). Registration bumps the revision so + * mounted outlets pick up late-arriving dictionaries. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @param dicts - complete dictionaries keyed by built-in locale id. + * @returns disposer removing every locale registered by this call (idempotent). + */ + register>( + ns: N, + dicts: Record>, + ): () => void; + /** + * Single-locale untyped form for language-pack contributions and namespaces + * outside the merge table. + * @param ns - namespace. + * @param locale - locale tag. + * @param dict - dictionary. + * @returns disposer (idempotent). + * @throws when locale is not a BCP 47-style tag. + */ + register(ns: string, locale: string, dict: LocaleDict): () => void; + register( + ns: string, + localeOrDicts: string | Record, + dict?: LocaleDict, + ): () => void { + const pairs: [string, LocaleDict][] = + typeof localeOrDicts === "string" + ? // Overload guarantees dict on the single-locale arm. + [[localeOrDicts, dict as LocaleDict]] + : Object.entries(localeOrDicts); + for (const [locale] of pairs) { + if (!LOCALE_ID_PATTERN.test(locale)) { + throw new Error(`locale id "${locale}" is not a BCP 47-style tag`); + } + } + let locales = this.dicts.get(ns); + if (!locales) { + locales = new Map(); + this.dicts.set(ns, locales); + } + for (const [locale] of pairs) { + if (locales.has(localeKey(locale))) { + throw new Error(`locale namespace "${ns}" already has locale "${locale}"`); + } + } + for (const [locale, entries] of pairs) locales.set(localeKey(locale), entries); + this.publish(this.snapshot.active, false); + return () => { + const owner = this.dicts.get(ns); + /* v8 ignore next -- defensive: a namespace's locales map is created on + * first register and never removed, so the disposer always finds it. */ + if (!owner) return; + let removed = false; + for (const [locale, entries] of pairs) { + const key = localeKey(locale); + if (owner.get(key) === entries) { + owner.delete(key); + removed = true; + } + } + if (removed) this.publish(this.snapshot.active, false); + }; + } + + /** + * Bind a declared namespace to a translate function typed to its + * dictionary key union (plus the shared common vocabulary) — the same key + * domain the framework-injected `t` seat carries. The returned reference + * is stable per namespace (repeat binds return the same function), so it + * can ride inject surfaces without breaking memoization. + * @param ns - a namespace merged into LocaleNamespaceMap. + * @returns the typed translate function (reads the active locale at call time). + */ + bind>(ns: N): TranslateNS; + /** + * Untyped form for namespaces outside the merge table (dynamic + * composition, tests). + * @param ns - namespace. + * @returns the translate function. + */ + bind(ns: string): Translate; + bind(ns: string): Translate { + let t = this.bound.get(ns); + if (!t) { + t = (key, params) => this.translate(ns, key, params); + this.bound.set(ns, t); + return t; + } + return t; + } + + private translate(ns: string, key: string, params?: Record): string { + const chain = this.fallbackChain(this.snapshot.active); + const template = + this.lookup(ns, key, chain) ?? + (ns !== COMMON_NS ? this.lookup(COMMON_NS, key, chain) : undefined) ?? + key; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); + } + + private lookup(ns: string, key: string, chain: readonly LocaleId[]): string | undefined { + const locales = this.dicts.get(ns); + for (const locale of chain) { + const value = locales?.get(localeKey(locale))?.[key]; + if (value !== undefined) return value; + } + return undefined; + } + + /** + * Advance the snapshot revision and notify LocaleFace subscribers (render + * refresh). Only an active-locale switch additionally emits + * `locale/change` — dictionary registrations stay off the event so + * registration-heavy boot cannot storm event listeners (which may + * re-register slots in response). + */ + private publish( + active: LocaleId, + localeChanged: boolean, + locales: readonly LocaleDefinition[] = this.snapshot.locales, + ): void { + this.snapshot = Object.freeze({ + active, + locales, + revision: this.snapshot.revision + 1, + }); + if (localeChanged) this.ctx.emit("locale/change", this.snapshot); + for (const fn of [...this.listeners]) { + try { + fn(); + } catch (error) { + // One throwing subscriber must not strand the rest on a stale + // revision (outlets would keep the previous language). + console.error("locale subscriber crashed:", error); + } + } + } +} + +/** + * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit + * Host preference may replace this provisional value after plugin activation. + */ +function resolveInitialLocale( + locales: readonly LocaleDefinition[], + languages?: readonly string[], +): LocaleId { + return detectBrowserLocale(locales, languages) ?? FALLBACK_LOCALE; +} + +/** + * The first registered locale the browser asks for. Each browser tag first + * matches a locale id exactly, then its primary subtag, so an exact regional + * registration wins before a language-wide fallback. + * `window` is the browser test, not `navigator`: Node exposes a global + * `navigator` reporting the machine's own language, which must not decide the + * locale for non-browser runs. `navigator.language` trails the ordered + * `languages` list and covers hosts exposing only the single tag. + * @param locales - definitions currently available to the browser. + * @param languages - native system language order, when supplied by a shell. + * @returns the first matching locale id, or undefined. + */ +function detectBrowserLocale( + locales: readonly LocaleDefinition[], + languages?: readonly string[], +): LocaleId | undefined { + if (languages === undefined) { + if (typeof window === "undefined") return undefined; + // Embedders and older WebViews may omit the DOM-typed `languages` property. + const browserLanguages = (navigator as { readonly languages?: readonly string[] }).languages; + languages = [...(browserLanguages ?? []), navigator.language]; + } + for (const tag of languages) { + const requested = localeKey(tag); + const exact = locales.find((locale) => localeKey(locale.id) === requested); + if (exact !== undefined) return exact.id; + const primary = requested.split("-")[0]; + const match = locales.find((locale) => localeKey(locale.id).split("-")[0] === primary); + if (match !== undefined) return match.id; + } + return undefined; +} + +/** Required services: slot registration plus the settings transport. */ +export const inject = ["slots", "remote", "configForms"]; + +/** + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). + * @param ctx - client cordis context. + * @returns resolves after native language initialization and plugin registration. + */ +export async function apply(ctx: ClientContext): Promise { + const bridge = (globalThis as { __DSH_LOCALE__?: LocaleBridge }).__DSH_LOCALE__; + let bootstrap: LocaleBootstrap | undefined; + if (bridge !== undefined) { + let value: unknown; + try { + value = await bridge.read(); + } catch (error) { + if (ctx.fiber.uid === null) return; + throw error; + } + if (ctx.fiber.uid === null) return; + bootstrap = parseLocaleBootstrap(value); + } + const host = ctx.configForms.get(LOCALE_SETTINGS_NAMESPACE); + const locale = new LocaleRuntime(ctx, host, bootstrap); + locale.register(COMMON_NS, { zh, en }); + locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }); + ctx.provide("locale", locale); + if (bridge !== undefined) { + ctx.on("locale/change", (snapshot) => { + bridge.onChange(snapshot.active); + }); + bridge.onChange(locale.getSnapshot().active); + } + // The service IS the LocaleFace (bind + getSnapshot/subscribe): install it + // so the render machinery can synthesize the `t` standard seat. + ctx.slots.installLocale(locale); + + const store = createLanguageRowStore(); + let bound: BoundActions | undefined; + const sync = (): void => { + const snapshot = locale.getSnapshot(); + syncDocumentLanguage(snapshot); + bound?.sync( + snapshot.active, + snapshot.locales.map((l) => ({ id: l.id, label: l.label })), + snapshot.revision, + ); + }; + ctx.effect(() => locale.subscribe(sync), "locale: language row and document synchronization"); + // The served markup declares one language; the resolved locale may differ + // (browser detection, or a stored preference adopted after activation), so + // state it once at activation rather than waiting for the first change. + sync(); + const injected = (actions: BoundActions): LanguageRowInjected => { + bound = actions; + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(); + return { + setLocale: (id) => { + locale.setLocale(id); + }, + }; + }; + ctx.slots.inject("settings.general.item", () => + ctx.slots.register( + { + name: "settings.general.item", + id: "language", + order: 0, + store, + locale: SETTINGS_NS, + inject: injected, + }, + LanguageRow, + ), + ); +} diff --git a/apps/web-ui/packages/client/locale/src/client/settings-store.ts b/apps/web-ui/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 000000000..49579cafd --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,52 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from "@deepseek-ai/dsh-client-store"; + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string; + /** Display name in its own language (中文 / English). */ + label: string; +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string; + /** Selectable locales in display order. */ + options: LanguageOptionRow[]; + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number; +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: ( + draft: LanguageRowState, + active: string, + options: LanguageOptionRow[], + revision: number, + ) => void; +}; + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle { + return defineStore({ + init: (): LanguageRowState => ({ active: "", options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return; + d.active = active; + d.options = options; + d.revision = revision; + }, + }, + }); +} diff --git a/apps/web-ui/packages/client/locale/src/css-modules.d.ts b/apps/web-ui/packages/client/locale/src/css-modules.d.ts new file mode 100644 index 000000000..b284bf80b --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css"; diff --git a/apps/web-ui/packages/client/locale/src/index.ts b/apps/web-ui/packages/client/locale/src/index.ts new file mode 100644 index 000000000..325c96b17 --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/index.ts @@ -0,0 +1,38 @@ +/** Host registration for the browser locale preference. */ +import type {} from "@deepseek-ai/dsh-settings"; + +import type { Volatile, Context } from "@deepseek-ai/cordis"; + +import z from "@deepseek-ai/schemastery"; +import { LOCALE_PREFERENCE_FIELD } from "./locale-settings.ts"; + +import { LocaleSettingsFields } from "./locale-settings.ts"; + +export { + LOCALE_IDS, + LOCALE_PREFERENCE_FIELD, + LOCALE_SETTINGS_NAMESPACE, + type BuiltInLocaleId, + type LocaleId, + type LocaleSettings, +} from "./locale-settings.ts"; + +/** Runtime preferences projected to the browser. */ +export interface Config { + /** Explicit locale; omission follows the browser. */ + preference: Volatile; +} + +/** Live preferences projected to the browser. */ +export const Config = z.object({ + [LOCALE_PREFERENCE_FIELD]: LocaleSettingsFields[LOCALE_PREFERENCE_FIELD].volatile(), +}); + +/** Host preferences are consumed through the configuration form projection. + * @param ctx Plugin context used for optional settings presentation. + */ +export function apply(ctx: Context): void { + ctx.inject(["settings"], (child) => { + child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)); + }); +} diff --git a/apps/web-ui/packages/client/locale/src/locale-settings.ts b/apps/web-ui/packages/client/locale/src/locale-settings.ts new file mode 100644 index 000000000..6fd5e662c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locale-settings.ts @@ -0,0 +1,35 @@ +/** Locale preference stored in the Host user-settings document. */ + +import z from "@deepseek-ai/schemastery"; + +/** Settings namespace owned by the locale plugin. */ +export const LOCALE_SETTINGS_NAMESPACE = "locale"; + +/** Field carrying an explicit locale selection; absence delegates to the browser. */ +export const LOCALE_PREFERENCE_FIELD = "preference"; + +/** Accepted BCP 47-style language ids. */ +export const LOCALE_ID_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u; + +/** Locale identifiers shipped by the browser client. */ +export const LOCALE_IDS = ["zh", "en"] as const; + +/** Locale identifier shipped by the browser client. */ +export type BuiltInLocaleId = (typeof LOCALE_IDS)[number]; + +/** Open locale identifier accepted from language-pack plugins. */ +export type LocaleId = string; + +/** Durable locale section shared by the Host schema and the browser scope. */ +export interface LocaleSettings { + /** Explicit locale selection; absence delegates to the browser. */ + preference?: LocaleId; +} + +/** Durable locale schema; also the wire envelope the browser scope validates against. */ +export const LocaleSettingsFields = { + [LOCALE_PREFERENCE_FIELD]: z.string().pattern(LOCALE_ID_PATTERN).required(false), +}; + +/** Schema for the shared locale preference. */ +export const LocaleSettingsSchema = z.object(LocaleSettingsFields); diff --git a/apps/web-ui/packages/client/locale/src/locales/en.ts b/apps/web-ui/packages/client/locale/src/locales/en.ts new file mode 100644 index 000000000..d9ff0844c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/en.ts @@ -0,0 +1,46 @@ +import type { CommonKey } from "./zh.ts"; + +/** en base dictionary for the common namespace, checked complete against the zh key set. */ +export const en = { + ok: "OK", + cancel: "Cancel", + close: "Close", + copy: "Copy", + copied: "Copied", + "codeBlock.title": "Code block", + "codeBlock.wrap": "Wrap lines", + "codeBlock.unwrap": "Do not wrap lines", + "copy.failed": "Copy failed", + "copy.value": "Copy value", + "copy.json": "Copy JSON", + "copy.path": "Copy property path", + "copy.prettyJson": "Copy pretty JSON", + "copy.compactJson": "Copy compact JSON", + "copy.optionsHint": "{action}; right-click for copy options", + retry: "Retry", + loading: "Loading…", + "load.failed": "Failed to load", + submit: "Submit", + submitting: "Submitting…", + next: "Next", + previous: "Previous", + skip: "Skip", + delete: "Delete", + edit: "Edit", + save: "Save", + search: "Search", + more: "More", + collapse: "Collapse", + expand: "Expand", + back: "Back", + "brand.localBuild": "DSH Local Build", + "workspace.defaultName": "Default workspace", + unknown: "Unknown", + none: "None", + truncated: "Truncated", + "json.label": "JSON", + "markdown.footnotes": "Footnotes", + "markdown.truncatedCharacters": "… truncated at {total} characters", + "number.thousand": "{value}K", + "number.million": "{value}M", +} satisfies Record; diff --git a/apps/web-ui/packages/client/locale/src/locales/index.ts b/apps/web-ui/packages/client/locale/src/locales/index.ts new file mode 100644 index 000000000..78a1b846f --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/index.ts @@ -0,0 +1,8 @@ +/** + * The common-namespace dictionary pair. zh is the source of truth for the + * key set (Chinese-first repo convention); en is checked complete against it + * — a missing or extra en key is a compile error. + */ +export { zh } from "./zh.ts"; +export { en } from "./en.ts"; +export type { CommonKey } from "./zh.ts"; diff --git a/apps/web-ui/packages/client/locale/src/locales/settings.ts b/apps/web-ui/packages/client/locale/src/locales/settings.ts new file mode 100644 index 000000000..790d4c48f --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/settings.ts @@ -0,0 +1,14 @@ +/** `settings.locale` namespace dictionaries (the Language row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + "language.title": "语言", +} satisfies Record; + +/** The settings.locale namespace key union. */ +export type SettingsLocaleKey = keyof typeof zh; + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + "language.title": "Language", +} satisfies Record; diff --git a/apps/web-ui/packages/client/locale/src/locales/zh.ts b/apps/web-ui/packages/client/locale/src/locales/zh.ts new file mode 100644 index 000000000..0a3d0289c --- /dev/null +++ b/apps/web-ui/packages/client/locale/src/locales/zh.ts @@ -0,0 +1,47 @@ +/** zh base dictionary for the common namespace: cross-feature standard words. */ +export const zh = { + ok: "确定", + cancel: "取消", + close: "关闭", + copy: "复制", + copied: "复制成功", + "codeBlock.title": "代码块", + "codeBlock.wrap": "自动换行", + "codeBlock.unwrap": "取消自动换行", + "copy.failed": "复制失败", + "copy.value": "复制值", + "copy.json": "复制 JSON", + "copy.path": "复制属性路径", + "copy.prettyJson": "复制格式化 JSON", + "copy.compactJson": "复制紧凑 JSON", + "copy.optionsHint": "{action};右键点击可选择复制方式", + retry: "重试", + loading: "加载中…", + "load.failed": "加载失败", + submit: "提交", + submitting: "正在提交…", + next: "下一步", + previous: "上一步", + skip: "跳过", + delete: "删除", + edit: "编辑", + save: "保存", + search: "搜索", + more: "更多", + collapse: "收起", + expand: "展开", + back: "返回", + "brand.localBuild": "DSH 本地构建", + "workspace.defaultName": "默认工作区", + unknown: "未知", + none: "无", + truncated: "已截断", + "json.label": "JSON", + "markdown.footnotes": "脚注", + "markdown.truncatedCharacters": "… 已截断,共 {total} 字符", + "number.thousand": "{value}K", + "number.million": "{value}M", +} satisfies Record; + +/** The common vocabulary key union (zh is the key-set source of truth). */ +export type CommonKey = keyof typeof zh; diff --git a/apps/web-ui/packages/client/locale/tsdown.config.ts b/apps/web-ui/packages/client/locale/tsdown.config.ts new file mode 100644 index 000000000..e5ae79cd9 --- /dev/null +++ b/apps/web-ui/packages/client/locale/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from "../tsdown.client.ts"; + +export default clientBundle("@deepseek-ai/dsh-client-locale", ["lib/types/index.js"]); diff --git a/apps/web-ui/packages/client/modules/package.json b/apps/web-ui/packages/client/modules/package.json new file mode 100644 index 000000000..c448257cb --- /dev/null +++ b/apps/web-ui/packages/client/modules/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepseek-ai/dsh-client-modules", + "version": "0.2.1-alpha.1", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dependencies": {}, + "dsh": { + "client": { + "platform": "web", + "inject": [], + "immediately": true + } + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/entries.ts b/apps/web-ui/packages/client/modules/src/client/entries.ts new file mode 100644 index 000000000..1fb46f2fb --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/entries.ts @@ -0,0 +1,268 @@ +/** Page-owned Loader entries; transport-independent reconciliation, retries and code replacement. */ +import type { FiberState } from "@deepseek-ai/cordis"; +import type { Entry, Loader } from "@deepseek-ai/cordis-plugin-loader"; +import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-store"; +import { parseBootManifest } from "./manifest.ts"; +import type { BootManifest, ClientModuleLoader } from "./manifest.ts"; +import { removeOwnedStyles, tearDownEntryFiber } from "./entry-lifecycle.ts"; + +/** Page-local failures do not change the Host's bundle enablement. */ +export interface ClientEntryState { + /** True while a snapshot, retry or code replacement is being applied. */ + readonly syncing: boolean; + /** Package ids and errors from the latest reconciliation. */ + readonly failures: readonly { readonly id: string; readonly message: string }[]; +} + +/** Module-table capabilities used within serialized entry operations. */ +interface ModuleIndex { + update(manifest: BootManifest, managed: Iterable): void; + invalidateForReplacement(id: string, rev: string): void; + prune(roots: Iterable): void; +} + +/** Numeric values mirror Cordis's const enum, which bundle loaders cannot import as a runtime object. */ +const ACTIVE = 2 as FiberState.ACTIVE; +const FAILED = 3 as FiberState.FAILED; + +/** Revisions and requests identify desired code; URLs only select its immutable delivery resource. */ +function entryTargets(manifest: BootManifest): string { + return JSON.stringify(manifest.modules.map((row) => [row.id, row.rev, row.inject, row.external])); +} + +/** Manages only entries created from the Host manifest; other Loader contributors retain ownership. */ +export class ClientEntries { + /** Stable observable consumed by page diagnostics through the renderer's injected hook. */ + readonly state: ObservableSnapshot = { + getSnapshot: () => this.snapshot, + subscribe: (listener) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + }; + // The modules bootstrap factory cannot request platform libraries before the shell supplies its seed. + private snapshot: ClientEntryState = { syncing: false, failures: [] }; + private readonly listeners = new Set<() => void>(); + private readonly managed = new Map(); + private readonly revisions = new Map(); + private loader: Loader | undefined; + private queue: Promise = Promise.resolve(); + private desired: BootManifest; + private generation = 0; + private stopped = false; + + /** + * Construct the page controller before Cordis boot. + * @param modules - Module arrival and materialization owner. + * @param index - Private descriptor replacement and unused-module cleanup. + */ + constructor( + private readonly modules: ClientModuleLoader, + private readonly index: ModuleIndex, + ) { + this.desired = modules.manifest; + } + + /** + * Create the initial roster and retain its entry identities for subsequent reconciliation. + * @param loader - Page Loader, already configured with the module system. + * @param manifest - Initial roster audited by the boot caller. + * @returns after initial entries and their activation settle; boot owns its activation audit. + */ + start(loader: Loader, manifest: BootManifest): Promise { + if (this.loader !== undefined) throw new Error("client-modules: entries already started"); + this.loader = loader; + this.desired = manifest; + loader.ctx.effect( + () => () => { + this.stopped = true; + this.generation++; + return this.queue; + }, + "client-modules: entry reconciliation", + ); + return this.enqueue(async () => { + await Promise.all( + this.desired.plugins.map(async ({ id }) => { + await this.create(loader, id); + }), + ); + await loader.await(); + for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev); + }); + } + + /** + * Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads. + * @param graph - JSON-decoded graph received from the Host. + * @returns after the queued reconciliation; per-package failures remain available in {@link state}. + */ + sync(graph: unknown): Promise { + const manifest = parseBootManifest(graph); + if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++; + this.desired = manifest; + const generation = this.generation; + return this.enqueue(() => this.reconcile(generation)); + } + + /** + * Retry failed entries against the latest graph, including an unchanged revision. + * @returns after retry settlement, with remaining errors in {@link state}. + */ + retry(): Promise { + const generation = ++this.generation; + return this.enqueue(() => this.reconcile(generation)); + } + + /** + * Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored. + * Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown. + * @param id - Package id from a rebuilt frame. + * @param rev - Opaque revision selecting the rebuilt artifact. + * @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}. + */ + reload(id: string, rev: string): Promise { + this.desired = { + ...this.desired, + modules: this.desired.modules.map((row) => (row.id === id ? { ...row, rev } : row)), + }; + return this.enqueue(async () => { + const desired = this.desired.modules.find((row) => row.id === id); + if (this.stopped || desired === undefined) return; + const entry = this.managed.get(id); + if (entry === undefined) { + this.modules.invalidate(id, desired.rev); + removeOwnedStyles(id); + await this.reconcile(this.generation); + return; + } + if (this.revisions.get(id) === rev) return; + this.publish({ + syncing: true, + failures: this.snapshot.failures.filter((failure) => failure.id !== id), + }); + await this.replace(entry, id, rev, this.generation); + this.publish({ syncing: false, failures: this.snapshot.failures }); + }, id); + } + + private publish(snapshot: ClientEntryState): void { + this.snapshot = snapshot; + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + // The page controller has no owning plugin Context for a scoped logger. + console.error("client-modules: synchronization subscriber failed", error); + } + } + } + + private enqueue(task: () => Promise, subject = "graph"): Promise { + const run = this.queue.then(task); + // Each operation reports its own failure; later operations must still run. + this.queue = run.then( + () => undefined, + (error: unknown) => { + this.publish({ + syncing: false, + failures: [ + ...this.snapshot.failures.filter((failure) => failure.id !== subject), + { id: subject, message: String(error) }, + ], + }); + }, + ); + return run; + } + + private current(generation: number): boolean { + return !this.stopped && generation === this.generation; + } + + /** Keep ownership even when Loader rejects a module's plugin exports after inserting its entry. */ + private async create(loader: Loader, id: string): Promise { + const options = { name: id }; + const entryId = loader.ensureId(options); + try { + await loader.create(options); + } finally { + this.managed.set(id, loader.resolve(entryId)); + } + } + + private async replace(entry: Entry, id: string, rev: string, generation: number): Promise { + this.index.invalidateForReplacement(id, rev); + await this.modules.prefetch(id); + if (!this.current(generation)) return; + await tearDownEntryFiber(entry); + removeOwnedStyles(id); + if (!this.current(generation)) return; + await this.modules.import(id, "", {}); + if (!this.current(generation)) return; + await entry.refresh(); + await entry.fiber?.await(); + if (entry.fiber === undefined) + throw new Error(`client-modules: ${id} import failed (see console)`); + this.revisions.set(id, rev); + } + + private async reconcile(generation: number): Promise { + if (!this.current(generation)) return; + const loader = this.loader; + if (loader === undefined) throw new Error("client-modules: entries have not started"); + const manifest = this.desired; + this.publish({ syncing: true, failures: [] }); + const failures: { id: string; message: string }[] = []; + this.index.update(manifest, this.managed.keys()); + const wanted = new Set(manifest.plugins.map((row) => row.id)); + for (const [id, entry] of this.managed) { + if (wanted.has(id)) continue; + const fiber = entry.fiber; + loader.remove(entry.id); + this.managed.delete(id); + this.revisions.delete(id); + // Removed fibers no longer appear in Loader.getTasks(). + while (fiber?.inertia !== undefined) await fiber.inertia; + } + for (const row of manifest.modules) { + if (!this.current(generation)) break; + try { + const entry = this.managed.get(row.id); + if (entry === undefined) { + await this.modules.prefetch(row.id); + if (!this.current(generation)) break; + await this.modules.import(row.id, "", {}); + if (!this.current(generation)) break; + await this.create(loader, row.id); + this.revisions.set(row.id, row.rev); + } else if (this.revisions.get(row.id) !== row.rev) { + await this.replace(entry, row.id, row.rev, generation); + } else if (entry.fiber === undefined) { + await this.replace(entry, row.id, row.rev, generation); + } else if (entry.fiber.state === FAILED) { + entry.fiber.update(entry.options.config); + } + } catch (error) { + failures.push({ id: row.id, message: String(error) }); + } + } + await loader.await(); + for (const [id, entry] of this.managed) { + if (failures.some((failure) => failure.id === id)) continue; + if (entry.fiber?.state === ACTIVE) continue; + try { + if (entry.fiber === undefined) + throw new Error(`client-modules: ${id} import failed (see console)`); + await entry.fiber.await(); + failures.push({ id, message: `client-modules: ${id} is waiting for activation` }); + } catch (error) { + failures.push({ id, message: String(error) }); + } + } + this.index.prune([...loader.entries()].map((entry) => entry.options.name)); + if (this.current(generation)) this.publish({ syncing: false, failures }); + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts b/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts new file mode 100644 index 000000000..43d216320 --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/entry-lifecycle.ts @@ -0,0 +1,28 @@ +/** Loader lifecycle operations shared by live graph reconciliation and code replacement. */ +import type { Entry } from "@deepseek-ai/cordis-plugin-loader"; + +/** + * Release a runtime before clearing its entry fiber so Loader refresh can import new code. + * Registry deletion prevents Loader from treating replacement as a user disable. + * @param entry - Entry retained for code replacement. + */ +export async function tearDownEntryFiber(entry: Entry): Promise { + const fiber = entry.fiber; + if (fiber === undefined) return; + const runtime = fiber.runtime; + /* v8 ignore next -- Loader entries own plugin fibers; only the root context has a null runtime. */ + if (runtime !== null) entry.ctx.registry.delete(runtime.callback); + while (fiber.inertia !== undefined) await fiber.inertia; + delete entry.fiber; +} + +/** + * Remove styles after their plugin's effect cleanup has settled. + * @param id - Package whose factory owns the style tags. + */ +export function removeOwnedStyles(id: string): void { + if (typeof document === "undefined") return; + for (const el of document.querySelectorAll("style[data-plugin]")) { + if (el.getAttribute("data-plugin") === id) el.remove(); + } +} diff --git a/apps/web-ui/packages/client/modules/src/client/index.ts b/apps/web-ui/packages/client/modules/src/client/index.ts new file mode 100644 index 000000000..2fdf5cb4d --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/index.ts @@ -0,0 +1,84 @@ +/** + * Browser half (the standard `./client` export): the module-system class and + * wire contract, plus the enrollment plugin face. The module system itself is + * built by the shell kernel BEFORE cordis exists (the bootstrap exception — + * the mechanism that loads plugins cannot arrive through itself). The host + * parser-preloads this ordinary client bundle into the pending registration + * queue. The HTML-installed loader facade materializes this bundle and calls + * its bootstrap export, which constructs the system and retains the same + * exports for this package's graph row. The plugin face enrolls the module + * system attached to its own Loader as `ctx.modules`. + * @module @deepseek-ai/dsh-client-modules/client + */ +import type { Context } from "@deepseek-ai/cordis"; +import type { Loader } from "@deepseek-ai/cordis-plugin-loader"; +import { ClientModuleSystem } from "./system.ts"; +import { parseBootManifest } from "./manifest.ts"; +import type { + ClientBootstrapModule, + ClientModuleCreateOptions, + ClientModuleLoader, + ClientModuleLoaderTarget, +} from "./manifest.ts"; + +export { ClientModuleSystem }; +export { tearDownEntryFiber } from "./entry-lifecycle.ts"; +export type { ClientEntries, ClientEntryState } from "./entries.ts"; +export { + exactPackageSpecifier, + parseBootManifest, + parseDshClient, + stripClientSuffix, +} from "./manifest.ts"; +export type { + BootManifest, + BootModuleRow, + BootPluginRow, + ClientBootstrapModule, + ClientBundleRegistration, + ClientModuleCreateOptions, + ClientModuleLoader, + ClientModuleLoaderTarget, + ClientModuleRecord, + ClientModuleSystemOptions, + DshWindow, + WebBootEntry, + WebBootGraph, +} from "./manifest.ts"; + +/** + * Build the live module system from the HTML facade's materialized modules bundle. + * @param target - Stable registration facade whose pending queue becomes the live sink. + * @param bootstrapModule - This bundle's id and already-materialized exports. + * @param options - Raw boot graph, platform seed, and optional bundle transport. + * @returns The created module system. + */ +export function createClientModuleSystem( + target: ClientModuleLoaderTarget, + bootstrapModule: ClientBootstrapModule, + options: ClientModuleCreateOptions, +): ClientModuleSystem { + return new ClientModuleSystem({ + manifest: parseBootManifest(options.boot), + staticModules: options.staticModules, + registrationTarget: target, + bootstrapModule, + ...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }), + }); +} + +/** Required service: the Loader whose internal module system this plugin publishes. */ +export const inject = ["loader"]; + +/** + * Enroll the kernel-built module system as `ctx.modules`. + * @param ctx - client root context. + */ +export function apply(ctx: Context): void { + const loader: Loader = ctx.loader; + const modules = loader.internal as unknown as ClientModuleLoader | undefined; + if (modules?.version !== "client") { + throw new Error("client-modules: the Loader has no client module system"); + } + ctx.reflect.provide("modules", modules); +} diff --git a/apps/web-ui/packages/client/modules/src/client/manifest.ts b/apps/web-ui/packages/client/modules/src/client/manifest.ts new file mode 100644 index 000000000..9fa5bc7f8 --- /dev/null +++ b/apps/web-ui/packages/client/modules/src/client/manifest.ts @@ -0,0 +1,465 @@ +/** + * Client module system: the browser peer of Node's internal ESM loader, built + * as a lazy CJS table. The vendored cordis Loader consumes this object + * through its `internal` contract (the only call site is `EntryTree.import` → + * `internal.import`), which keeps entry governance (fiber lifecycle, inject + * waiting, update/refresh) entirely on the vendored side while this package + * owns code arrival. + * + * Lazy CJS model: executing a plugin bundle only REGISTERS its + * factory (`window.__ModuleLoader__.load({id, factory})`); every module body + * side effect — including CSS injection — lives inside the factory closure + * and runs at materialization, not at script execution. Materialization + * (factory(require) → exports) happens on first import/require and is + * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires + * another registered-but-unmaterialized module materializes it recursively, + * so load order needs no external sequencing. + * + * Resolution branch order (import): seed word → shell instance; memoized + * record → exports; graph row → register its dependency factories and own + * factory; registered factory → materialize; anything else → throw (loud — + * the runtime mirror of the build-time bundle purity gate). + * The synchronous `require` handed to factories walks the same order minus + * the load branch. Loading is async, so a requested dynamic package must have + * registered its factory before a consumer materializes. + * + * This file is the browser-safe contract face (zero node imports): the + * `__DSH_BOOT__` wire types, the boot-manifest parser, and the boundaries around + * {@link ClientModuleSystem}. The package root is the host-side service that + * composes the wire. + */ + +import type {} from "@deepseek-ai/cordis"; +import type { DshClientManifest } from "@deepseek-ai/dsh-package-manifest"; +import type { ClientEntries } from "./entries.ts"; +import type { ClientModuleSystem } from "./system.ts"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */ + modules: ClientModuleLoader; + } +} + +/** + * One composed client entry pushed by the host (a graph row). Wire + * single source: the host node half (package root) produces this same shape. + * `immediately` marks stage-one prefetch. `inject` names package rows whose + * factories must arrive before this row materializes, while Cordis separately + * uses the same package edges to compose entries. `external` carries exact + * non-inject module requests (see {@link WebBootGraph.entries}). + */ +export interface WebBootEntry { + /** Entry name == package name. */ + id: string; + /** + * Revisioned single-resource combo reference used by HMR. It is relative to + * the document, so the browser resolves it under whatever mount served the page. + */ + url: string; + /** Opaque plugin-artifact revision used for HMR cache busting. */ + rev: string; + /** Package-name dependency edges used for factory arrival and plugin composition. */ + inject?: string[]; + /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */ + immediately?: boolean; + /** Non-baseline module specifiers this row requests; omitted when it requests none. */ + external?: string[]; +} + +/** Initial scheduling phase for one revisioned combo script. */ +export type WebBootBatchPhase = "bootstrap" | "application"; + +/** One initial combo script; a scheduling phase may span several descriptors. */ +export interface WebBootBatch { + /** Parser-blocking bootstrap or preloaded application scheduling. */ + phase: WebBootBatchPhase; + /** Content-addressed combo script reference, document-relative like {@link WebBootEntry.url}. */ + url: string; + /** Revision derived from the ordered entry revisions. */ + rev: string; + /** Graph entry ids whose factories the script registers, in execution order. */ + entries: string[]; +} + +/** The composed client entry graph the host injects as `window.__DSH_BOOT__`. */ +export interface WebBootGraph { + /** Consistency anchor over the current entry and batch descriptors. */ + rev: string; + /** + * Composed entries in module-graph order — a dynamic package row precedes + * rows whose `external` requests that package. Cordis activation order is + * unrelated and remains owned by fiber service waiting. + */ + entries: WebBootEntry[]; + /** Initial combo descriptors; every entry belongs to exactly one descriptor. */ + batches: WebBootBatch[]; +} + +/** The npm-package view of one boot row: what the module table needs to fetch the bundle. */ +export interface BootModuleRow { + /** Entry name == package name (module-table key). */ + id: string; + /** Revisioned single-resource combo reference: the fallback when the row's batch fails and the reload target after HMR invalidation. */ + url: string; + /** Content-addressed combo reference used before the first HMR invalidation. */ + initialUrl: string; + /** Opaque plugin-artifact revision used after HMR invalidation. */ + rev: string; + /** Injected package rows whose factories arrive before this row materializes. */ + inject: string[]; + /** Module specifiers this row requests from the module table ([] when the wire omits them). */ + external: string[]; +} + +/** The cordis-plugin view of one boot row: what entry composition needs (optional wire fields normalized). */ +export interface BootPluginRow { + /** Entry name == package name. */ + id: string; + /** Package-name dependency edges ([] when the wire omits them). */ + inject: string[]; + /** Stage-one prefetch tier (false when the wire omits it). */ + immediately: boolean; +} + +/** The parsed boot manifest: one wire, two consumer views. */ +export interface BootManifest { + /** Consistency anchor over the whole graph. */ + rev: string; + /** Rows as the module table consumes them. */ + modules: BootModuleRow[]; + /** Rows as entry composition consumes them. */ + plugins: BootPluginRow[]; +} + +/** + * Validate an optional string-array field read from a `dsh.client` declaration + * or from the boot wire. + * @param subject - diagnostic prefix naming the package or the wire row. + * @param field - field name as it appears in the diagnostic. + * @param value - the raw field value. + * @returns the validated array, or undefined when the field is absent. + * @throws {Error} when the value is present but is not an array of strings. + */ +export function optionalStringArray( + subject: string, + field: string, + value: unknown, +): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new Error(`client-modules: ${subject} ${field} must be a string array`); + } + return value as string[]; +} + +/** + * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared + * by the node half's Loader scan and the roster generator, so both read a + * package's browser declaration through one validator. + * @param pkgName - package name used as the diagnostic prefix. + * @param value - the raw `dsh.client` field of the package manifest. + * @returns the validated declaration, or undefined when the field is absent. + * @throws {Error} when the field is present but any member is malformed. + */ +export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null) { + throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`); + } + const decl = value as Record; + if (typeof decl.platform !== "string") { + throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`); + } + const inject = optionalStringArray(pkgName, "dsh.client.inject", decl.inject); + const external = optionalStringArray(pkgName, "dsh.client.external", decl.external); + if (decl.immediately !== undefined && typeof decl.immediately !== "boolean") { + throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`); + } + return { + platform: decl.platform, + ...(inject !== undefined ? { inject } : {}), + ...(external !== undefined ? { external } : {}), + ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), + }; +} + +/** + * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified + * specifier (`cordis:` builtins, `node:` modules, URLs). + * @param specifier - Loader row name. + * @returns the package name, or undefined. + */ +export function exactPackageSpecifier(specifier: string): string | undefined { + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + return parts.length === 2 && parts.every(Boolean) ? specifier : undefined; + } + return specifier.length > 0 && !specifier.includes("/") && !specifier.includes(":") + ? specifier + : undefined; +} + +/** + * Normalize a module specifier onto the graph row that owns it: a plugin bundle + * IS its package's client half, so `/client` (the exports subpath external + * bundles emit) and the bare package name resolve to the same exports. Both the + * require path and graph composition normalize here, which is what lets each + * importing package request the subpath its own code imports. + * @param spec - module specifier as a bundle requires it or a declaration spells it. + * @returns the specifier with a trailing `/client` removed. + */ +export function stripClientSuffix(spec: string): string { + return spec.endsWith("/client") ? spec.slice(0, -"/client".length) : spec; +} + +/** + * Parse `window.__DSH_BOOT__` into the two consumer views. Wire boundary: + * a missing or malformed graph throws (the shell shows the loud failure — + * a page without a valid manifest cannot boot anything). + * @param wire - the raw `window.__DSH_BOOT__` value. + * @returns the manifest with optional plugin-view fields normalized. + */ +export function parseBootManifest(wire: unknown): BootManifest { + if (typeof wire !== "object" || wire === null) { + throw new Error("client-modules: window.__DSH_BOOT__ is missing or not an object"); + } + const graph = wire as Record; + if (typeof graph.rev !== "string") { + throw new Error("client-modules: boot manifest rev must be a string"); + } + if (!Array.isArray(graph.entries)) { + throw new Error("client-modules: boot manifest entries must be an array"); + } + if (!Array.isArray(graph.batches)) { + throw new Error("client-modules: boot manifest batches must be an array"); + } + const moduleFields: Omit[] = []; + const plugins: BootPluginRow[] = []; + const seenEntryIds = new Set(); + for (const value of graph.entries as unknown[]) { + if (typeof value !== "object" || value === null) { + throw new Error("client-modules: boot manifest entry is not an object"); + } + const row = value as Record; + const where = typeof row.id === "string" ? `"${row.id}"` : JSON.stringify(row); + if (typeof row.id !== "string" || typeof row.url !== "string" || typeof row.rev !== "string") { + throw new Error(`client-modules: boot manifest entry ${where} must carry string id/url/rev`); + } + if (seenEntryIds.has(row.id)) + throw new Error(`client-modules: duplicate graph entry "${row.id}"`); + seenEntryIds.add(row.id); + const subject = `boot manifest entry ${where}`; + const inject = optionalStringArray(subject, "inject", row.inject); + const external = optionalStringArray(subject, "external", row.external); + if (row.immediately !== undefined && typeof row.immediately !== "boolean") { + throw new Error(`client-modules: boot manifest entry ${where} immediately must be a boolean`); + } + moduleFields.push({ + id: row.id, + url: row.url, + rev: row.rev, + inject: inject === undefined ? [] : [...inject], + external: external === undefined ? [] : [...external], + }); + plugins.push({ + id: row.id, + inject: inject === undefined ? [] : [...inject], + immediately: row.immediately === true, + }); + } + + const entryIds = new Set(moduleFields.map((row) => row.id)); + const initialUrls = new Map(); + const batchUrls = new Set(); + for (const value of graph.batches as unknown[]) { + if (typeof value !== "object" || value === null) { + throw new Error("client-modules: boot manifest batch is not an object"); + } + const batch = value as Record; + const phase = batch.phase; + if (phase !== "bootstrap" && phase !== "application") { + throw new Error( + `client-modules: boot manifest batch phase must be "bootstrap" or "application", received ${JSON.stringify(phase)}`, + ); + } + if (typeof batch.url !== "string" || typeof batch.rev !== "string") { + throw new Error(`client-modules: boot manifest ${phase} batch must carry string url/rev`); + } + if (batchUrls.has(batch.url)) { + throw new Error( + `client-modules: boot manifest carries duplicate batch URL ${JSON.stringify(batch.url)}`, + ); + } + batchUrls.add(batch.url); + const entries = optionalStringArray(`boot manifest ${phase} batch`, "entries", batch.entries); + if (entries === undefined || entries.length === 0) { + throw new Error( + `client-modules: boot manifest ${phase} batch entries must be a non-empty string array`, + ); + } + for (const id of entries) { + if (!entryIds.has(id)) { + throw new Error(`client-modules: boot manifest ${phase} batch names unknown entry "${id}"`); + } + if (initialUrls.has(id)) { + throw new Error( + `client-modules: boot manifest entry "${id}" belongs to more than one batch`, + ); + } + initialUrls.set(id, batch.url); + } + } + const modules = moduleFields.map((row): BootModuleRow => { + const initialUrl = initialUrls.get(row.id); + if (initialUrl === undefined) { + throw new Error( + `client-modules: boot manifest entry "${row.id}" belongs to no initial-load batch`, + ); + } + return { ...row, initialUrl }; + }); + return { rev: graph.rev, modules, plugins }; +} + +/** Module resolver passed into a registered Client bundle factory. */ +export interface ClientBundleRequire { + /** Resolve a module-table dependency synchronously. */ + (specifier: string): unknown; + /** Load and resolve a package-local dynamic chunk asynchronously. */ + async(specifier: string): Promise; +} + +/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */ +export interface ClientBundleRegistration { + /** Plugin id (package name) — the registration key; must match the graph row being executed. */ + id: string; + /** Package-local chunk filename; absent for the package's `client.js` entry. */ + chunk?: string; + /** + * Closure factory holding the whole bundle body: receives the module-table + * require whose `async` operation loads generated chunks, and returns the + * bundle's exports. The factory runs once, at materialization. + */ + factory: (require: ClientBundleRequire) => Record; +} + +/** Inputs passed by the web entry when it creates the client module system. */ +export interface ClientModuleCreateOptions { + /** Raw Host-injected boot graph; the modules bundle owns validation and projection. */ + boot: unknown; + /** Module-table seed: platform-singleton specifier → shell instance. */ + staticModules: Record; + /** Bundle-load hook. Defaults to a same-origin classic ``; +} + +function globalScript(name: string, value: unknown): string { + return scriptTag( + `globalThis[${JSON.stringify(name)}] = ${JSON.stringify(value).replace(/{ +const pendingQueue=[] +window.__ModuleLoader__={ + mode:"queue", + pendingQueue, + load(registration){pendingQueue.push(registration)}, + create(options){ + if(this.mode!=="queue")throw new Error("client-modules: window.__ModuleLoader__.create called after module-system boot") + const index=pendingQueue.findIndex(registration=>registration.id==="${CLIENT_MODULES_ID}") + const registration=pendingQueue[index] + if(registration===undefined)throw new Error("client-modules: HTML did not preload ${CLIENT_MODULES_ID}/client.js") + pendingQueue.splice(index,1) + const exports=registration.factory(specifier=>{ + throw new Error('client-modules: ${CLIENT_MODULES_ID}/client.js requested external "'+specifier+'" before the module system existed') + }) + if(typeof exports!=="object"||exports===null||typeof exports.createClientModuleSystem!=="function"||typeof exports.apply!=="function"){ + throw new Error("client-modules: ${CLIENT_MODULES_ID}/client.js did not export the bootstrap module face") + } + return exports.createClientModuleSystem(this,{id:registration.id,exports},options) + } +} +})()`; + +const THEME_SCRIPT = `(() => { + const preference = "system" + const systemDark = preference === 'system' + && typeof matchMedia !== 'undefined' + && matchMedia('(prefers-color-scheme: dark)').matches + const dark = preference === 'dark' || systemDark + document.documentElement.dataset.dsThemeSource = preference + document.body.toggleAttribute('data-ds-dark-theme', dark) + document.body.style.setProperty('--dsh-content-font-size', "14px") +})()`; + +/** Composed plugin graph plus the responses that serve it. */ +export class WebAssets { + readonly distRoot: string; + readonly graph: BootGraph; + private readonly records: PluginRecord[]; + private readonly byId: Map; + private readonly combos = new Map(); + private readonly indexHtml: Buffer; + + constructor(private readonly options: WebAssetOptions) { + if (options.packedRoot !== undefined) { + this.distRoot = resolve(options.packedRoot); + const packed = JSON.parse( + readFileSync(join(this.distRoot, "plugins.json"), "utf8"), + ) as PackedPlugin[]; + const wanted = new Set(options.plugins); + const records = packed + .filter((plugin) => wanted.size === 0 || wanted.has(plugin.id)) + .map((plugin) => { + const dir = join(this.distRoot, "plugins", plugin.dir); + const clientPath = join(dir, "client.js"); + const stat = statSync(clientPath); + return { + id: plugin.id, + dir, + clientPath, + manifest: plugin.manifest, + rev: shortHash(`${stat.mtimeMs}:${stat.size}:${plugin.id}`), + }; + }); + this.records = orderByModuleGraph(records); + this.byId = new Map(this.records.map((record) => [record.id, record])); + this.graph = this.composeGraph(); + this.indexHtml = this.renderIndex(); + return; + } + if (options.repoRoot === undefined) + throw new Error("web-assets: repoRoot or packedRoot is required"); + this.distRoot = resolve(options.repoRoot, "apps/web/dist"); + const packages = scanWorkspacePackages(options.repoRoot); + const records: PluginRecord[] = []; + for (const id of options.plugins) { + const dir = packages.get(id); + if (dir === undefined) throw new Error(`web-assets: unknown client package ${id}`); + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { + exports?: unknown; + dsh?: { client?: ClientManifest & { platform?: string } }; + }; + const manifest = pkg.dsh?.client; + if (manifest === undefined || manifest.platform !== "web") + throw new Error(`web-assets: ${id} declares no web client`); + const rel = clientExportOf(pkg.exports); + if (rel === undefined) throw new Error(`web-assets: ${id} exports no ./client bundle`); + const clientPath = join(dir, rel); + const stat = statSync(clientPath); + records.push({ + id, + dir, + clientPath, + manifest, + rev: shortHash(`${stat.mtimeMs}:${stat.size}:${id}`), + }); + } + const present = new Set(records.map((record) => record.id)); + const missing: string[] = []; + const isClientPlugin = (name: string): boolean => { + const dir = packages.get(name); + if (dir === undefined) return false; + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { + dsh?: { client?: { platform?: string } }; + }; + return pkg.dsh?.client?.platform === "web"; + }; + for (const record of records) { + for (const dependency of record.manifest.inject ?? []) { + // Shell-provided platform modules (for example ui-primitives) are not graph rows. + if (!present.has(dependency) && isClientPlugin(dependency)) + missing.push(`${record.id} -> ${dependency}`); + } + } + if (missing.length > 0) { + throw new Error( + `web-assets: plugins inject packages outside the composed set:\n ${missing.join("\n ")}`, + ); + } + this.records = orderByModuleGraph(records); + this.byId = new Map(this.records.map((record) => [record.id, record])); + this.graph = this.composeGraph(); + this.indexHtml = this.renderIndex(); + } + + private composeGraph(): BootGraph { + const entries: BootEntry[] = this.records.map((record) => ({ + id: record.id, + url: `plugins/??${record.id}/client.js&rev=${record.rev}`, + rev: record.rev, + ...(record.manifest.inject !== undefined ? { inject: record.manifest.inject } : {}), + ...(record.manifest.immediately === true ? { immediately: true as const } : {}), + ...((record.manifest.external?.length ?? 0) > 0 + ? { external: record.manifest.external } + : {}), + })); + const bootstrap = this.records.filter((record) => record.id === CLIENT_MODULES_ID); + const application = this.records.filter((record) => record.id !== CLIENT_MODULES_ID); + const batches: BootBatch[] = []; + for (const [phase, group] of [ + ["bootstrap", bootstrap], + ["application", application], + ] as const) { + if (group.length === 0) continue; + const ids = group.map((record) => record.id); + const body = Buffer.from( + group.map((record) => prepareSource(readFileSync(record.clientPath, "utf8"))).join(""), + ); + const rev = shortHash(body); + const url = `plugins/??${ids.map((id) => `${id}/client.js`).join(",")}&rev=${rev}`; + this.combos.set(this.comboKey(ids), { body, contentType: "text/javascript; charset=utf-8" }); + batches.push({ phase, url, rev, entries: ids }); + } + return { rev: shortHash(JSON.stringify({ entries, batches })), entries, batches }; + } + + private comboKey(ids: readonly string[]): string { + return ids.join(","); + } + + private renderIndex(): Buffer { + const template = readFileSync(join(this.distRoot, "index.html"), "utf8"); + const head: string[] = [ + '', + "", + ]; + for (const [name, value] of Object.entries(this.options.globals)) + head.push(globalScript(name, value)); + head.push(scriptTag(MODULE_QUEUE)); + for (const batch of this.graph.batches.filter((item) => item.phase === "application")) { + head.push(``); + } + for (const batch of this.graph.batches.filter((item) => item.phase === "bootstrap")) { + head.push(``); + } + head.push(globalScript("__DSH_BOOT__", this.graph)); + const body = + scriptTag(THEME_SCRIPT) + + scriptTag("(globalThis.__DSH_BOOT_READY__ ??= Promise.withResolvers()).resolve()"); + const html = template + .replace("", `\n${head.join("\n")}`) + .replace(/[^<]*<\/title>/u, `<title>${this.options.title}`) + .replace("", `${body}`); + return Buffer.from(html); + } + + index(): AssetResponse { + return { body: this.indexHtml, contentType: "text/html; charset=utf-8" }; + } + + /** + * Answer one `/plugins/...` request. + * @param pathAndSearch - the request path including its search string. + */ + plugin(pathAndSearch: string): AssetResponse | undefined { + const url = new URL(pathAndSearch, "http://local"); + const search = decodeURIComponent(url.search); + if (url.pathname === "/plugins/" && search.startsWith("??")) { + const resourceEnd = search.indexOf("&rev="); + const resources = (resourceEnd < 0 ? search.slice(2) : search.slice(2, resourceEnd)).split( + ",", + ); + if (resources.some((resource) => resource.endsWith(".map"))) { + return { + body: Buffer.from('{"version":3,"sources":[],"mappings":""}'), + contentType: "application/json", + }; + } + const ids = resources.map((resource) => resource.replace(/\/client\.js$/u, "")); + const cached = this.combos.get(this.comboKey(ids)); + if (cached !== undefined) return cached; + const records = ids.map((id) => this.byId.get(id)); + if (records.some((record) => record === undefined)) return undefined; + const body = Buffer.from( + records + .map((record) => prepareSource(readFileSync((record as PluginRecord).clientPath, "utf8"))) + .join(""), + ); + const response = { body, contentType: "text/javascript; charset=utf-8" }; + this.combos.set(this.comboKey(ids), response); + return response; + } + // Package-local chunk: /plugins// + const rest = url.pathname.slice("/plugins/".length); + const slash = rest.lastIndexOf("/"); + if (slash < 0) return undefined; + const id = decodeURIComponent(rest.slice(0, slash)); + const fileName = rest.slice(slash + 1); + const record = this.byId.get(id); + if (record === undefined) return undefined; + if (fileName.endsWith(".map")) { + return { + body: Buffer.from('{"version":3,"sources":[],"mappings":""}'), + contentType: "application/json", + }; + } + if (!CLIENT_CHUNK.test(fileName)) return undefined; + const chunkPath = join(dirname(record.clientPath), fileName); + if (!existsSync(chunkPath)) return undefined; + return { + body: Buffer.from(prepareSource(readFileSync(chunkPath, "utf8"))), + contentType: "text/javascript; charset=utf-8", + }; + } + + /** + * Write a self-contained copy of the composed plugins for a packed distribution. + * @param outDir - the distribution's `web/` directory. + */ + exportPlugins(outDir: string): PackedPlugin[] { + const rows: PackedPlugin[] = []; + for (const record of this.records) { + const dir = record.id.replace(/^@/u, "").replace(/\//gu, "__"); + const target = join(outDir, "plugins", dir); + mkdirSync(target, { recursive: true }); + const sourceDir = dirname(record.clientPath); + for (const name of readdirSync(sourceDir)) { + if (name === "client.js" || CLIENT_CHUNK.test(name)) + copyFileSync(join(sourceDir, name), join(target, name)); + } + rows.push({ id: record.id, dir, manifest: record.manifest }); + } + writeFileSync(join(outDir, "plugins.json"), `${JSON.stringify(rows, null, 2)}\n`); + return rows; + } + + /** Static file from the Vite shell output. */ + dist(pathname: string): AssetResponse | undefined { + const target = resolve(this.distRoot, `.${decodeURIComponent(pathname)}`); + if (!target.startsWith(this.distRoot) || !existsSync(target) || !statSync(target).isFile()) + return undefined; + return { body: readFileSync(target), contentType: contentTypeOf(target) }; + } +} + +const CONTENT_TYPES: Record = { + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".webmanifest": "application/manifest+json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + ".wasm": "application/wasm", + ".map": "application/json; charset=utf-8", +}; + +function contentTypeOf(path: string): string { + const dot = path.lastIndexOf("."); + return CONTENT_TYPES[dot < 0 ? "" : path.slice(dot)] ?? "application/octet-stream"; +} diff --git a/packages/web-server/src/web-push.d.ts b/packages/web-server/src/web-push.d.ts new file mode 100644 index 000000000..b5861679a --- /dev/null +++ b/packages/web-server/src/web-push.d.ts @@ -0,0 +1,12 @@ +declare module "web-push" { + const webpush: { + generateVAPIDKeys(): { publicKey: string; privateKey: string }; + setVapidDetails(subject: string, publicKey: string, privateKey: string): void; + sendNotification( + subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, + payload: string, + options?: { TTL?: number; urgency?: string }, + ): Promise; + }; + export default webpush; +} diff --git a/packages/web-server/src/workspaces.ts b/packages/web-server/src/workspaces.ts new file mode 100644 index 000000000..908d889f3 --- /dev/null +++ b/packages/web-server/src/workspaces.ts @@ -0,0 +1,235 @@ +/** Workspace registry and the `workspace/*` Remote surface. */ + +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, statSync } from "node:fs"; +import { basename, isAbsolute, join, resolve } from "node:path"; +import { homedir } from "node:os"; + +import { type DataDir, requestOf } from "./store.ts"; +import { RpcError, type RpcRegistry, type StreamRegistry, type StreamSink } from "./transport.ts"; + +export interface WorkspaceView { + workspaceId: string; + path: string; + title: string; + sessionIds: string[]; + createdAt: string; + updatedAt: string; +} + +interface WorkspaceState { + items: WorkspaceView[]; + archivedSessionIds: string[]; + pinnedSessionIds: string[]; +} + +const FILE = "workspaces.json"; + +export class Workspaces { + private state: WorkspaceState; + private readonly followers = new Set(); + + constructor( + private readonly data: DataDir, + private readonly defaultDirectory: string, + ) { + this.state = data.readJson(FILE, { + items: [], + archivedSessionIds: [], + pinnedSessionIds: [], + }); + } + + private save(): void { + this.data.writeJson(FILE, this.state); + } + + private broadcast(frame: unknown): void { + for (const sink of this.followers) sink.push(frame); + } + + private touch(workspace: WorkspaceView): void { + workspace.updatedAt = new Date().toISOString(); + this.save(); + this.broadcast({ type: "upsert", workspace }); + } + + list(): readonly WorkspaceView[] { + return this.state.items; + } + + get(workspaceId: string): WorkspaceView | undefined { + return this.state.items.find((item) => item.workspaceId === workspaceId); + } + + byPath(path: string): WorkspaceView | undefined { + return this.state.items.find((item) => item.path === path); + } + + ownerOf(sessionId: string): WorkspaceView | undefined { + return this.state.items.find((item) => item.sessionIds.includes(sessionId)); + } + + isArchived(sessionId: string): boolean { + return this.state.archivedSessionIds.includes(sessionId); + } + + create(path: string): { workspace: WorkspaceView; created: boolean } { + const expanded = path.startsWith("~") ? join(homedir(), path.slice(1)) : path; + if (!isAbsolute(expanded)) + throw new RpcError("workspace/invalid-path", `Workspace path must be absolute: ${path}`, { + path, + }); + const absolute = resolve(expanded); + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + throw new RpcError("workspace/invalid-path", `Not a directory: ${absolute}`, { path }); + } + const existing = this.byPath(absolute); + if (existing !== undefined) return { workspace: existing, created: false }; + const now = new Date().toISOString(); + const workspace: WorkspaceView = { + workspaceId: randomUUID(), + path: absolute, + title: basename(absolute) || absolute, + sessionIds: [], + createdAt: now, + updatedAt: now, + }; + this.state.items.push(workspace); + this.save(); + this.broadcast({ type: "upsert", workspace }); + this.broadcast({ + type: "order", + workspaceIds: this.state.items.map((item) => item.workspaceId), + }); + return { workspace, created: true }; + } + + /** Attach a session to the workspace owning `cwd` (creating one when needed). */ + attachSession( + sessionId: string, + workspaceId: string | undefined, + cwd: string | undefined, + ): WorkspaceView { + let workspace = workspaceId === undefined ? undefined : this.get(workspaceId); + if (workspace === undefined && cwd !== undefined) + workspace = this.byPath(resolve(cwd)) ?? this.create(cwd).workspace; + if (workspace === undefined) + throw new RpcError("workspace/invalid-path", "Session needs a workspace or cwd", { + path: cwd ?? "", + }); + if (!workspace.sessionIds.includes(sessionId)) { + workspace.sessionIds.unshift(sessionId); + this.touch(workspace); + } + return workspace; + } + + detachSession(sessionId: string): void { + for (const workspace of this.state.items) { + const index = workspace.sessionIds.indexOf(sessionId); + if (index >= 0) { + workspace.sessionIds.splice(index, 1); + this.touch(workspace); + } + } + } + + register(rpc: RpcRegistry, streams: StreamRegistry): void { + rpc.register("workspace/create", (args) => this.create(requestOf<{ path: string }>(args).path)); + rpc.register("workspace/initializeDefault", () => { + if (this.state.items.length > 0) return undefined; + mkdirSync(this.defaultDirectory, { recursive: true }); + return { workspace: this.create(this.defaultDirectory).workspace }; + }); + rpc.register("workspace/rename", (args) => { + const request = requestOf<{ workspaceId: string; title: string }>(args); + const workspace = this.require(request.workspaceId); + workspace.title = request.title; + this.touch(workspace); + return { workspace }; + }); + rpc.register("workspace/delete", (args) => { + const request = requestOf<{ workspaceId: string }>(args); + this.require(request.workspaceId); + this.state.items = this.state.items.filter( + (item) => item.workspaceId !== request.workspaceId, + ); + this.save(); + this.broadcast({ type: "remove", workspaceId: request.workspaceId }); + return { deleted: true }; + }); + rpc.register("workspace/insertBefore", (args) => { + const request = requestOf<{ workspaceId: string; beforeWorkspaceId?: string }>(args); + const workspace = this.require(request.workspaceId); + const rest = this.state.items.filter((item) => item !== workspace); + const index = + request.beforeWorkspaceId === undefined + ? rest.length + : rest.findIndex((item) => item.workspaceId === request.beforeWorkspaceId); + rest.splice(index < 0 ? rest.length : index, 0, workspace); + this.state.items = rest; + this.save(); + const workspaceIds = rest.map((item) => item.workspaceId); + this.broadcast({ type: "order", workspaceIds }); + return { workspaceIds }; + }); + rpc.register("workspace/insertSessionBefore", (args) => { + const request = requestOf<{ + workspaceId: string; + sessionId: string; + beforeSessionId?: string; + }>(args); + const workspace = this.require(request.workspaceId); + for (const other of this.state.items) { + if (other !== workspace && other.sessionIds.includes(request.sessionId)) { + other.sessionIds = other.sessionIds.filter((id) => id !== request.sessionId); + this.touch(other); + } + } + const rest = workspace.sessionIds.filter((id) => id !== request.sessionId); + const index = + request.beforeSessionId === undefined ? rest.length : rest.indexOf(request.beforeSessionId); + rest.splice(index < 0 ? rest.length : index, 0, request.sessionId); + workspace.sessionIds = rest; + this.touch(workspace); + return { workspace }; + }); + const setMembership = + ( + key: "archivedSessionIds" | "pinnedSessionIds", + frame: "archived" | "pinned", + add: boolean, + ) => + (args: Record) => { + const { sessionId } = requestOf<{ sessionId: string }>(args); + const current = this.state[key].filter((id) => id !== sessionId); + if (add) current.push(sessionId); + this.state[key] = current; + this.save(); + this.broadcast({ type: frame, [key]: current }); + return { [key]: current }; + }; + rpc.register("workspace/archiveSession", setMembership("archivedSessionIds", "archived", true)); + rpc.register( + "workspace/unarchiveSession", + setMembership("archivedSessionIds", "archived", false), + ); + rpc.register("workspace/pinSession", setMembership("pinnedSessionIds", "pinned", true)); + rpc.register("workspace/unpinSession", setMembership("pinnedSessionIds", "pinned", false)); + streams.register("workspace/follow", (_args, sink) => { + this.followers.add(sink); + sink.onClose(() => this.followers.delete(sink)); + sink.push({ type: "baseline", value: this.state }); + }); + } + + private require(workspaceId: string): WorkspaceView { + const workspace = this.get(workspaceId); + if (workspace === undefined) + throw new RpcError("workspace/not-found", `Unknown workspace ${workspaceId}`, { + workspaceId, + }); + return workspace; + } +} diff --git a/packages/web-server/test/browser/web.test.ts b/packages/web-server/test/browser/web.test.ts new file mode 100644 index 000000000..204f2535c --- /dev/null +++ b/packages/web-server/test/browser/web.test.ts @@ -0,0 +1,138 @@ +/** Keyless browser acceptance against the real Web composition and a scripted Harness. */ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; + +import { chromium, type Page } from "playwright"; + +import { startServer } from "../support/server.ts"; + +const serverRoot = resolve(import.meta.dirname, "../.."); +const composerLabel = "Describe what you want to build, / commands, @ files or sessions"; + +async function screenshot(page: Page, name: string): Promise { + const directory = process.env.CODEXHOST_TEST_SCREENSHOTS; + if (directory === undefined) return; + mkdirSync(directory, { recursive: true }); + await page.screenshot({ path: join(directory, `${name}.png`), animations: "disabled" }); +} + +for (const viewport of [ + { width: 320, height: 640 }, + { width: 390, height: 844 }, + { width: 1280, height: 900 }, +]) { + it( + `settings remain usable and persist at ${viewport.width}px`, + { timeout: 60_000 }, + async (t) => { + const url = await startServer( + t, + serverRoot, + ["--import", "tsx", "src/main.ts"], + ["--adapters", "test/fake-harness"], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport, + isMobile: viewport.width < 600, + hasTouch: viewport.width < 600, + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await page.getByRole("textbox", { name: composerLabel, exact: true }).waitFor(); + if (viewport.width < 600) + await page.getByRole("button", { name: "Open sidebar", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Settings", exact: true }); + await dialog.getByRole("button", { name: "English", exact: true }).waitFor(); + for (const name of ["English", "Light", "Dark", "System", "Close"]) { + const box = await dialog.getByRole("button", { name, exact: true }).boundingBox(); + assert.ok(box, `${name} has a visible box`); + assert.ok( + box.x >= 0 && box.x + box.width <= viewport.width, + `${name} is not clipped horizontally`, + ); + } + // The section must get the phone's width, not the remainder of a desktop navigation rail. + const section = dialog + .getByText("Notifications", { exact: true }) + .locator("..") + .locator(".."); + const bounds = await section.boundingBox(); + assert.ok(bounds && bounds.width >= Math.min(260, viewport.width - 64)); + assert.doesNotMatch(await dialog.innerText(), /DeepSeek|Open configuration file/u); + await screenshot(page, `settings-${viewport.width}`); + await dialog.getByRole("button", { name: "Dark", exact: true }).click(); + await dialog.getByRole("button", { name: "Dark", exact: true, pressed: true }).waitFor(); + await screenshot(page, `settings-dark-${viewport.width}`); + await dialog.getByRole("button", { name: "Queue", exact: true }).scrollIntoViewIfNeeded(); + await screenshot(page, `settings-scrolled-${viewport.width}`); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await dialog.waitFor({ state: "hidden" }); + await page.reload(); + await page.getByRole("textbox", { name: composerLabel, exact: true }).waitFor(); + if (viewport.width < 600) + await page.getByRole("button", { name: "Open sidebar", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Dark", exact: true, pressed: true }) + .waitFor(); + assert.deepEqual(errors, []); + }, + ); +} + +it( + "a phone can send, reconnect, and reopen durable conversation history", + { timeout: 60_000 }, + async (t) => { + const url = await startServer( + t, + serverRoot, + ["--import", "tsx", "src/main.ts"], + ["--adapters", "test/fake-harness"], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await page.getByRole("textbox", { name: composerLabel, exact: true }).fill("mobile acceptance"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("echo: mobile acceptance", { exact: true }).waitFor(); + await screenshot(page, "mobile-conversation"); + await context.setOffline(true); + await page.getByRole("button", { name: "Open sidebar", exact: true }).click(); + await page.getByText("Disconnected", { exact: true }).waitFor(); + await screenshot(page, "mobile-disconnected"); + await context.setOffline(false); + await page.getByText("Connected", { exact: true }).waitFor(); + await screenshot(page, "mobile-reconnected"); + await page.getByRole("button", { name: "Collapse sidebar", exact: true }).click(); + await page + .getByRole("textbox", { + name: "Message or run a task, / commands, @ files or sessions", + exact: true, + }) + .fill("after reconnect"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("echo: after reconnect", { exact: true }).waitFor(); + await page.reload(); + await page.getByText("echo: after reconnect", { exact: true }).waitFor(); + assert.equal(await page.getByText("echo: mobile acceptance", { exact: true }).count(), 1); + assert.equal(await page.getByText("echo: after reconnect", { exact: true }).count(), 1); + await screenshot(page, "mobile-history"); + assert.deepEqual(errors, []); + }, +); diff --git a/packages/web-server/test/distribution/pack.test.ts b/packages/web-server/test/distribution/pack.test.ts new file mode 100644 index 000000000..c1cd1b884 --- /dev/null +++ b/packages/web-server/test/distribution/pack.test.ts @@ -0,0 +1,135 @@ +/** Pack and relocate the distribution away from the source and plugin input directories. */ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + copyFileSync, + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { promisify } from "node:util"; + +import { build } from "esbuild"; + +import { startServer } from "../support/server.ts"; + +const exec = promisify(execFile); +const serverRoot = resolve(import.meta.dirname, "../.."); + +it( + "runs bundled plugins after relocation without Desktop or workspace module resolution", + { timeout: 60_000 }, + async (t) => { + const root = mkdtempSync(join(tmpdir(), "codexhost-distribution-")); + t.after(() => { + rmSync(root, { recursive: true, force: true }); + }); + const input = join(root, "input"); + const plugin = join(input, "fake"); + mkdirSync(join(plugin, "assets"), { recursive: true }); + await build({ + entryPoints: [join(serverRoot, "test/fake-harness/fake/plugin.ts")], + outfile: join(plugin, "plugin.mjs"), + bundle: true, + platform: "node", + format: "esm", + }); + writeFileSync( + join(plugin, "manifest.json"), + JSON.stringify({ + id: "fake", + name: "Fake Harness", + entry: "./plugin.mjs", + icon: "./assets/icon.svg", + }), + ); + writeFileSync(join(plugin, "assets/icon.svg"), ''); + const output = join(root, "output"); + await exec( + process.execPath, + ["--import", "tsx", "scripts/pack.ts", output, "--adapters", input, "--harness", "fake"], + { cwd: serverRoot }, + ); + const moved = join(root, "relocated"); + renameSync(output, moved); + rmSync(input, { recursive: true }); + const url = await startServer(t, root, [join(moved, "server.mjs")], ["--harness", "fake"]); + assert.match( + readFileSync(join(moved, "README.md"), "utf8"), + /Included Harness plugins: codex, fake/u, + ); + const index = await fetch(url); + assert.equal(index.status, 200); + assert.match(await index.text(), /CodexHost/u); + assert.equal((await fetch(new URL("harness-icons/fake", url))).status, 200); + const catalog = await fetch(new URL("api/session/modelCatalog", url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + type: "client-request", + rpcId: "test", + method: "session/modelCatalog", + payload: { args: {} }, + }), + }); + const response = (await catalog.json()) as { + result: { ok: boolean; value: { groups: Array<{ id: string }> } }; + }; + assert.equal(response.result.ok, true); + assert.deepEqual( + response.result.value.groups.map((group) => group.id), + ["fake"], + ); + + // Exercise the shipped launcher with a fake server; never invoke Desktop or a real Harness. + const preview = join(root, "preview-fixture"); + mkdirSync(join(preview, "adapters/claude-code"), { recursive: true }); + copyFileSync(join(moved, "preview.mjs"), join(preview, "preview.mjs")); + writeFileSync( + join(preview, "adapters/claude-code/manifest.json"), + JSON.stringify({ id: "claude-code" }), + ); + writeFileSync( + join(preview, "server.mjs"), + "console.log(JSON.stringify({args:process.argv.slice(2),env:process.env}))\n", + ); + const { stdout } = await exec( + process.execPath, + [join(preview, "preview.mjs"), "--port", "0", "--data", join(preview, "data")], + { + cwd: root, + env: { + PATH: process.env.PATH, + HOME: root, + CODEXHOST_CODEX_COMMAND: "/unsafe/codex", + CODEXHOST_RUNTIME_TOKEN: "test-host-token", + CODEXHOST_WEB_DATA: "/desktop/data", + CODEXHOST_IMPORT_RECENT: "10", + NODE_OPTIONS: "--no-warnings", + NODE_PATH: "/unsafe/modules", + }, + }, + ); + const received = JSON.parse(stdout) as { args: string[]; env: NodeJS.ProcessEnv }; + assert.equal( + Object.keys(received.env).some((key) => key.startsWith("CODEXHOST_")), + false, + ); + assert.equal(received.env.NODE_OPTIONS, undefined); + assert.equal(received.env.NODE_PATH, undefined); + assert.equal(received.args[received.args.indexOf("--harness") + 1], "claude-code"); + assert.equal( + received.args[received.args.indexOf("--adapters") + 1], + realpathSync(join(preview, "adapters")), + ); + assert.equal(received.args[received.args.indexOf("--data") + 1], join(preview, "data")); + assert.equal(received.args.includes("--no-auth"), false); + }, +); diff --git a/packages/web-server/test/distribution/source-plugins.test.ts b/packages/web-server/test/distribution/source-plugins.test.ts new file mode 100644 index 000000000..e15d2247a --- /dev/null +++ b/packages/web-server/test/distribution/source-plugins.test.ts @@ -0,0 +1,53 @@ +/** Default packaging must consume plugins built from the same checkout. */ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const serverRoot = resolve(import.meta.dirname, "../.."); +const repoRoot = resolve(serverRoot, "../.."); + +it( + "copies current checkout artifacts without an installed Desktop plugin input", + { timeout: 60_000 }, + async (t) => { + const root = mkdtempSync(join(tmpdir(), "codexhost-source-pack-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const output = join(root, "web"); + // No --adapters override: this exercises the same default as build:web. + await exec( + process.execPath, + ["--import", "tsx", "scripts/pack.ts", output, "--harness", "claude-code,pi"], + { cwd: serverRoot }, + ); + assert.deepEqual(readdirSync(join(output, "adapters")).sort(), ["claude-code", "codex", "pi"]); + for (const id of ["claude-code", "pi"]) { + const source = join(repoRoot, "packages/host-runtime/dist/plugins", id); + const copied = join(output, "adapters", id); + assert.deepEqual( + readFileSync(join(copied, "manifest.json")), + readFileSync(join(source, "manifest.json")), + ); + const manifest = JSON.parse(readFileSync(join(source, "manifest.json"), "utf8")) as { + entry: string; + }; + assert.deepEqual( + readFileSync(join(copied, manifest.entry)), + readFileSync(join(source, manifest.entry)), + ); + } + assert.deepEqual( + readFileSync(join(output, "LICENSE")), + readFileSync(join(repoRoot, "LICENSE")), + ); + assert.deepEqual( + readFileSync(join(output, "licenses/DSH-MIT.txt")), + readFileSync(join(repoRoot, "apps/web-ui/LICENSE")), + ); + assert.ok(readFileSync(join(output, "licenses/THIRD_PARTY_NOTICES.md")).length > 0); + }, +); diff --git a/packages/web-server/test/fake-harness/fake/manifest.json b/packages/web-server/test/fake-harness/fake/manifest.json new file mode 100644 index 000000000..851e48ee8 --- /dev/null +++ b/packages/web-server/test/fake-harness/fake/manifest.json @@ -0,0 +1,8 @@ +{ + "manifestVersion": 1, + "id": "fake", + "name": "Fake Harness", + "version": "0.0.0", + "adapterApiVersion": 1, + "entry": "./plugin.ts" +} diff --git a/packages/web-server/test/fake-harness/fake/plugin.ts b/packages/web-server/test/fake-harness/fake/plugin.ts new file mode 100644 index 000000000..4ae2af6d0 --- /dev/null +++ b/packages/web-server/test/fake-harness/fake/plugin.ts @@ -0,0 +1,230 @@ +/** + * Scriptable Harness for server integration tests. A turn's input text selects a script from + * `globalThis.fakeHarnessScripts`; steps emit Host events, raise interactions, or wait for the + * interaction response before continuing. + */ + +type Step = + | { event: Record & { type: string } } + | { interaction: Record & { type: string; interactionId: string } } + | { awaitResponse: string } + | { awaitCancel: true }; + +declare global { + var fakeHarnessScripts: Record | undefined; + var fakeHarnessLog: Array> | undefined; + var fakeHarnessHistory: Record | undefined; + var fakeHarnessSubagents: Record | undefined; +} + +class Channel { + private values: unknown[] = []; + private waiters: Array<(result: IteratorResult) => void> = []; + private ended = false; + readonly outputs = { + [Symbol.asyncIterator]: () => ({ + next: () => { + const value = this.values.shift(); + if (value !== undefined) return Promise.resolve({ done: false, value }); + if (this.ended) return Promise.resolve({ done: true, value: undefined }); + return new Promise>((resolve) => this.waiters.push(resolve)); + }, + }), + }; + emit(value: unknown): void { + const waiter = this.waiters.shift(); + if (waiter !== undefined) waiter({ done: false, value }); + else this.values.push(value); + } + end(): void { + this.ended = true; + for (const waiter of this.waiters.splice(0)) waiter({ done: true, value: undefined }); + } +} + +let nextSession = 0; + +class FakeSession { + readonly harnessId = "fake"; + readonly capabilities = { + configuration: { selectModel: true, selectThinkingOption: true, selectPermissionMode: true }, + history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, + }; + readonly initialUsage = null; + readonly initialState: Record; + private readonly channel = new Channel(); + readonly outputs = this.channel.outputs; + private responses = new Map void>(); + private cancel: (() => void) | undefined; + + constructor(readonly nativeId: string) { + this.initialState = { + nativeRef: { harnessId: "fake", nativeSessionId: nativeId }, + effectiveModel: { id: "fake-model" }, + effectivePermissionModeId: "ask", + }; + } + + private async play(turnId: string, steps: Step[]): Promise { + this.channel.emit({ kind: "event", event: { type: "turn.started", turnId } }); + for (const step of steps) { + if ("event" in step) this.channel.emit({ kind: "event", event: { turnId, ...step.event } }); + else if ("interaction" in step) + this.channel.emit({ kind: "interaction", interaction: { turnId, ...step.interaction } }); + else if ("awaitResponse" in step) { + const response = await new Promise((resolve) => + this.responses.set(step.awaitResponse, resolve), + ); + globalThis.fakeHarnessLog?.push({ responded: step.awaitResponse, response }); + } else { + await new Promise((resolve) => { + this.cancel = resolve; + }); + this.channel.emit({ + kind: "event", + event: { type: "turn.completed", turnId, outcome: { status: "cancelled" } }, + }); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } + } + + async execute(command: Record & { type: string }): Promise { + globalThis.fakeHarnessLog?.push({ + command: command.type, + ...(command.type === "turn.start" + ? { text: (command.input as Array<{ text: string }>)[0]?.text } + : {}), + }); + switch (command.type) { + case "turn.start": { + const text = (command.input as Array<{ text: string }>).map((part) => part.text).join("\n"); + const steps = globalThis.fakeHarnessScripts?.[text] ?? [ + { + event: { + type: "item.completed", + snapshot: { + item: { + type: "agentMessage", + itemId: `m-${String(Date.now())}`, + text: `echo: ${text}`, + }, + outcome: { status: "succeeded" }, + }, + }, + }, + { event: { type: "turn.completed", outcome: { status: "succeeded" } } }, + ]; + void this.play(String(command.turnId), steps); + return { ok: true, value: { turnId: command.turnId } }; + } + case "turn.cancel": + this.cancel?.(); + return { ok: true, value: { cancellationRequested: true } }; + case "interaction.respond": { + const resolve = this.responses.get(String(command.interactionId)); + resolve?.(command.response); + this.channel.emit({ + kind: "event", + event: { + type: "interaction.closed", + interactionId: command.interactionId, + reason: "responded", + }, + }); + return { ok: true, value: { accepted: true } }; + } + default: + return { ok: true, value: { completed: true } }; + } + } + + async readSnapshot(): Promise { + return { ok: true, value: { turns: globalThis.fakeHarnessHistory?.[this.nativeId] ?? [] } }; + } + + async close(): Promise { + this.channel.end(); + } +} + +export function createHarnessAdapter(): unknown { + return { + harnessId: "fake", + async inspect() { + return { + status: "ready", + catalog: { + models: [{ ref: { id: "fake-model" }, label: "Fake Model" }], + defaultModel: { id: "fake-model" }, + thinkingOptions: [], + }, + permissionModes: { + modes: [ + { id: "ask", label: "Ask" }, + { id: "yolo", label: "Yolo" }, + ], + defaultModeId: "ask", + }, + capabilities: { + configuration: { + selectModel: true, + selectThinkingOption: false, + selectPermissionMode: true, + }, + history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, + }, + }; + }, + async open(input: { kind: string; nativeRef?: { nativeSessionId: string } }) { + globalThis.fakeHarnessLog?.push({ open: input.kind }); + return { + ok: true, + value: new FakeSession( + input.nativeRef?.nativeSessionId ?? `fake-native-${String(++nextSession)}`, + ), + }; + }, + sessionImport: { + async listCandidates() { + return { + ok: true, + value: Object.keys(globalThis.fakeHarnessHistory ?? {}).map((id, index) => ({ + nativeSessionId: id, + title: `History ${id}`, + updatedAt: 1_000_000 + index, + cwd: process.env.FAKE_HARNESS_CWD ?? "/tmp", + running: null, + })), + }; + }, + async resolveCandidate(id: string) { + if (globalThis.fakeHarnessHistory?.[id] === undefined) + return { ok: false, error: { code: "sessionNotFound", message: "missing" } }; + return { + ok: true, + value: { + candidate: { + nativeSessionId: id, + title: `History ${id}`, + updatedAt: 1_000_000, + cwd: process.env.FAKE_HARNESS_CWD ?? "/tmp", + running: null, + }, + nativeRef: { harnessId: "fake", nativeSessionId: id }, + }, + }; + }, + }, + subagents: { + async readSnapshot(input: { nativeSubagentId: string }) { + return { + ok: true, + value: { turns: globalThis.fakeHarnessSubagents?.[input.nativeSubagentId] ?? [] }, + }; + }, + }, + async close() {}, + }; +} diff --git a/packages/web-server/test/fixtures/claude-question-spanning-tool.jsonl b/packages/web-server/test/fixtures/claude-question-spanning-tool.jsonl new file mode 100644 index 000000000..2ba6bef6f --- /dev/null +++ b/packages/web-server/test/fixtures/claude-question-spanning-tool.jsonl @@ -0,0 +1,14 @@ +{"time":1791017233098,"kind":"event","event":{"type":"session.state.changed","state":{"nativeRef":{"harnessId":"claude-code","nativeSessionId":"8a9eb426-a683-4c74-946f-39c36fa5965a","formatVersion":1},"effectiveModel":{"id":"claude-model-v1.ZGVmYXVsdA"},"effectiveThinkingOptionId":"auto","availableThinkingOptions":[{"id":"off","label":"Off"},{"id":"auto","label":"Auto"},{"id":"low","label":"Low"},{"id":"medium","label":"Medium"},{"id":"high","label":"High"},{"id":"xhigh","label":"Extra High"},{"id":"max","label":"Max"}],"effectivePermissionModeId":"default"}}} +{"time":1791017233099,"kind":"event","event":{"type":"turn.started","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e"}} +{"time":1791017233099,"kind":"event","event":{"type":"item.started","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","item":{"type":"agentMessage","itemId":"claude-item-v2-5996a473-21a8-4429-ba3d-6b912ada4c8a-agentMessage-1","text":""}}} +{"time":1791017294016,"kind":"event","event":{"type":"item.started","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","item":{"type":"toolExecution","itemId":"claude-item-v2-5996a473-21a8-4429-ba3d-6b912ada4c8a-tool-1","toolName":"AskUserQuestion","arguments":{"questions":[{"question":"Which color do you prefer?","header":"Color","multiSelect":false,"options":[{"label":"Red","description":"You prefer red."},{"label":"Blue","description":"You prefer blue."}]}]}}}} +{"time":1791017294017,"kind":"event","event":{"type":"session.usage.changed","observedForTurnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","usage":{"cacheHitRatePercent":73.17916277783539,"inputTokens":28929,"outputTokens":24,"totalCostUsd":0.05967625}}} +{"time":1791017294024,"kind":"event","event":{"type":"session.usage.changed","observedForTurnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","usage":{"cacheHitRatePercent":73.17916277783539,"inputTokens":28929,"outputTokens":24,"totalCostUsd":0.05967625,"planFiveHourUsedPercent":28,"planFiveHourResetsAtUnix":1791033600,"planSevenDayUsedPercent":17,"planSevenDayResetsAtUnix":1791568800}}} +{"time":1791017294047,"kind":"interaction","interaction":{"type":"question","interactionId":"24277f91-c164-4af5-97f5-d9f746f9deb5","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","title":"Color","questions":[{"id":"question-1","type":"choice","prompt":"Which color do you prefer?","options":[{"value":"Red","label":"Red","description":"You prefer red."},{"value":"Blue","label":"Blue","description":"You prefer blue."}],"multiple":false,"allowOther":true,"optional":false}]}} +{"time":1791017294050,"kind":"event","event":{"type":"interaction.closed","interactionId":"24277f91-c164-4af5-97f5-d9f746f9deb5","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","reason":"responded"}} +{"time":1791017294066,"kind":"event","event":{"type":"item.completed","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","snapshot":{"item":{"type":"toolExecution","itemId":"claude-item-v2-5996a473-21a8-4429-ba3d-6b912ada4c8a-tool-1","toolName":"AskUserQuestion","arguments":{"questions":[{"question":"Which color do you prefer?","header":"Color","multiSelect":false,"options":[{"label":"Red","description":"You prefer red."},{"label":"Blue","description":"You prefer blue."}]}]},"output":{"content":[{"type":"text","text":"Your questions have been answered: \"Which color do you prefer?\"=\"Blue\". You can now continue with these answers in mind."}]},"durationMs":51},"outcome":{"status":"succeeded"}}}} +{"time":1791017398779,"kind":"event","event":{"type":"item.updated","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","itemId":"claude-item-v2-5996a473-21a8-4429-ba3d-6b912ada4c8a-agentMessage-1","update":{"type":"text.append","text":"Blue"}}} +{"time":1791017398788,"kind":"event","event":{"type":"session.usage.changed","observedForTurnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","usage":{"cacheHitRatePercent":99.3099423235375,"inputTokens":58057,"outputTokens":29,"totalCostUsd":0.0755185,"planFiveHourUsedPercent":28,"planFiveHourResetsAtUnix":1791033600,"planSevenDayUsedPercent":17,"planSevenDayResetsAtUnix":1791568800}}} +{"time":1791017398789,"kind":"event","event":{"type":"item.completed","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","snapshot":{"item":{"type":"agentMessage","itemId":"claude-item-v2-5996a473-21a8-4429-ba3d-6b912ada4c8a-agentMessage-1","text":"Blue"},"outcome":{"status":"succeeded"}}}} +{"time":1791017398822,"kind":"event","event":{"type":"session.usage.changed","observedForTurnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","usage":{"cacheHitRatePercent":86.28933634187092,"inputTokens":934,"outputTokens":143,"totalCostUsd":0.0773084,"planFiveHourUsedPercent":28,"planFiveHourResetsAtUnix":1791033600,"planSevenDayUsedPercent":17,"planSevenDayResetsAtUnix":1791568800}}} +{"time":1791017398823,"kind":"event","event":{"type":"turn.completed","turnId":"turn-eb476334-5b37-4079-9a04-1ffdcd036b7e","nativeTurnRef":{"harnessId":"claude-code","nativeSessionId":"8a9eb426-a683-4c74-946f-39c36fa5965a","nativeTurnKey":"5996a473-21a8-4429-ba3d-6b912ada4c8a","formatVersion":1},"outcome":{"status":"succeeded","checkpoint":{"harnessId":"claude-code","nativeSessionId":"8a9eb426-a683-4c74-946f-39c36fa5965a","checkpointId":"df3245fc-52de-43dd-b4d5-75e510954f3d","formatVersion":1}}}} diff --git a/packages/web-server/test/packaged-adapters.test.ts b/packages/web-server/test/packaged-adapters.test.ts new file mode 100644 index 000000000..1c741cd9d --- /dev/null +++ b/packages/web-server/test/packaged-adapters.test.ts @@ -0,0 +1,76 @@ +/** Distribution selection accepts relocatable plugins, not development source packages. */ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, it } from "node:test"; + +import { readPackagedAdapters } from "../src/packaged-adapters.ts"; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "codexhost-pack-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function plugin(directory: string, id = directory, entry = "./plugin.mjs"): string { + const path = join(root, directory); + mkdirSync(join(path, "assets"), { recursive: true }); + writeFileSync( + join(path, "manifest.json"), + JSON.stringify({ id, name: id, entry, icon: "assets/icon.svg" }), + ); + writeFileSync(join(path, "plugin.mjs"), "export function createHarnessAdapter() {}\n"); + writeFileSync(join(path, "assets/icon.svg"), ""); + return path; +} + +it("excludes usage-only plugins from conversations and rejects explicit selection", () => { + const session = plugin("session"); + const usage = plugin("usage"); + writeFileSync( + join(usage, "manifest.json"), + JSON.stringify({ id: "usage", name: "Usage", kind: "usage", entry: "./plugin.mjs" }), + ); + assert.deepEqual(readPackagedAdapters(root), [{ id: "session", directory: session }]); + assert.throws(() => readPackagedAdapters(root, new Set(["usage"])), /not found: usage/u); +}); + +it("selects only requested plugins and retains their resource directory", () => { + const directory = plugin("one"); + plugin("two"); + writeFileSync(join(root, "enabled.json"), "{}"); + assert.deepEqual(readPackagedAdapters(root, new Set(["one"])), [{ id: "one", directory }]); +}); + +it("rejects a missing requested Harness instead of emitting an incomplete distribution", () => { + plugin("one"); + assert.throws(() => readPackagedAdapters(root, new Set(["missing"])), /not found: missing/u); +}); + +it("rejects source entries and missing resources before packing", () => { + const directory = plugin("one", "one", "./plugin.ts"); + assert.throws(() => readPackagedAdapters(root), /prebuilt plugin/u); + plugin("one"); + rmSync(join(directory, "assets/icon.svg")); + assert.throws(() => readPackagedAdapters(root), /ENOENT/u); +}); + +it("rejects duplicate ids and collision with the bundled Codex adapter", () => { + plugin("one"); + plugin("two", "one"); + assert.throws(() => readPackagedAdapters(root), /Duplicate Harness id/u); + rmSync(join(root, "two"), { recursive: true }); + plugin("codex"); + assert.throws(() => readPackagedAdapters(root), /codex is bundled separately/u); +}); + +it("rejects ids and entry paths that escape the plugin directory", () => { + plugin("one", "../escape"); + assert.throws(() => readPackagedAdapters(root), /Cannot include Harness id/u); + writeFileSync(join(root, "outside.mjs"), ""); + plugin("one", "one", "../outside.mjs"); + assert.throws(() => readPackagedAdapters(root), /inside its directory/u); +}); diff --git a/packages/web-server/test/preview-launch.test.ts b/packages/web-server/test/preview-launch.test.ts new file mode 100644 index 000000000..64feddd30 --- /dev/null +++ b/packages/web-server/test/preview-launch.test.ts @@ -0,0 +1,82 @@ +/** Preview process isolation without executing any real Harness or Desktop entry point. */ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it } from "node:test"; +import { promisify } from "node:util"; + +import { previewLaunch } from "../src/preview-launch.ts"; + +const exec = promisify(execFile); + +it("removes inherited Host routing and Node injection without changing the parent environment", () => { + const parent = { + PATH: "/usr/bin", + HOME: "/home/test", + ANTHROPIC_API_KEY: "test-credential", + CODEXHOST_RUNTIME_TOKEN: "host-token", + CODEXHOST_CODEX_COMMAND: "/unsafe/codex", + CODEXHOST_LAUNCHER_EXECUTABLE: "/unsafe/launcher", + CODEXHOST_IMPORT_RECENT: "10", + CODEXHOST_WEB_DATA: "/desktop/data", + CODEXHOST_FUTURE_OPTION: "unsafe", + NODE_OPTIONS: "--import=/unsafe/hook.mjs", + NODE_PATH: "/unsafe/modules", + }; + const original = { ...parent }; + const { env, args } = previewLaunch("/packed", {}, parent); + assert.deepEqual(env, { + PATH: "/usr/bin", + HOME: "/home/test", + ANTHROPIC_API_KEY: "test-credential", + }); + assert.deepEqual(parent, original); + assert.equal(args[args.indexOf("--harness") + 1], "claude-code"); + assert.equal(args[args.indexOf("--adapters") + 1], "/packed/adapters"); + assert.equal(args[args.indexOf("--host") + 1], "127.0.0.1"); + assert.match(args[args.indexOf("--data") + 1] as string, /\.codexhost-web-preview$/u); + assert.match( + args[args.indexOf("--workspace") + 1] as string, + /codexhost-web-preview-workspace$/u, + ); + assert.equal(args.includes("--no-auth"), false); + assert.equal(args.includes("--import-recent"), false); +}); + +it("passes only isolated settings to the actual child process", async (t) => { + const root = mkdtempSync(join(tmpdir(), "codexhost-preview-")); + t.after(() => { + rmSync(root, { recursive: true, force: true }); + }); + writeFileSync( + join(root, "server.mjs"), + "console.log(JSON.stringify({args:process.argv.slice(2),env:process.env}))\n", + ); + const launch = previewLaunch( + root, + { port: "0", data: join(root, "data"), workspace: join(root, "workspace") }, + { + PATH: process.env.PATH, + HOME: root, + CODEXHOST_LAUNCHER_PID: "123", + CODEXHOST_RUNTIME_ENDPOINT: "unsafe", + CODEXHOST_RUNTIME_TOKEN: "test-host-token", + CODEXHOST_CODEX_COMMAND: "/unsafe/proxy", + NODE_OPTIONS: "--import=/missing-hook.mjs", + }, + ); + const { stdout } = await exec(process.execPath, launch.args, { env: launch.env }); + const received = JSON.parse(stdout) as { args: string[]; env: NodeJS.ProcessEnv }; + assert.deepEqual(received.args, launch.args.slice(1)); + for (const [key, value] of Object.entries(launch.env)) assert.equal(received.env[key], value); + assert.equal( + Object.keys(received.env).some((key) => key.startsWith("CODEXHOST_")), + false, + ); + assert.equal(received.env.NODE_OPTIONS, undefined); + assert.equal(received.env.NODE_PATH, undefined); + assert.equal(received.args[received.args.indexOf("--harness") + 1], "claude-code"); + assert.equal(received.args[received.args.indexOf("--data") + 1], join(root, "data")); +}); diff --git a/packages/web-server/test/projector.test.ts b/packages/web-server/test/projector.test.ts new file mode 100644 index 000000000..cd78e55df --- /dev/null +++ b/packages/web-server/test/projector.test.ts @@ -0,0 +1,395 @@ +import assert from "node:assert/strict"; +import { defined } from "./support/defined.ts"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { + TurnProjector, + diffTexts, + normalizeTool, + type HostItem, + type HostItemOutcome, + type HostItemUpdate, + type TurnOutcomeShape, +} from "../src/projector.ts"; +import { SessionLog, type WireEvent } from "../src/session-log.ts"; +import { DataDir } from "../src/store.ts"; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function newLog(): SessionLog { + const root = mkdtempSync(join(tmpdir(), "codexhost-projector-")); + dirs.push(root); + return new SessionLog( + { version: 4, id: "session-test", createdAt: 0, isSeeded: false }, + new DataDir(root), + () => {}, + ); +} + +/** Feed one Harness output the way Sessions.onEvent does. */ +function feed(projector: TurnProjector, event: Record & { type: string }): void { + switch (event.type) { + case "item.started": + projector.itemStarted(event.item as HostItem); + break; + case "item.updated": + projector.itemUpdated(String(event.itemId), event.update as HostItemUpdate); + break; + case "item.completed": { + const snapshot = event.snapshot as { item: HostItem; outcome: HostItemOutcome }; + projector.itemCompleted(snapshot.item, snapshot.outcome); + break; + } + case "turn.completed": + projector.finish(event.outcome as TurnOutcomeShape); + break; + default: + break; + } +} + +function messages( + events: readonly WireEvent[], +): Array<{ step: number; content: Array<{ type: string; text?: string; name?: string }> }> { + return events + .filter((event) => event.type === "assistant/message") + .map((event) => { + const data = event.data as { + step: number; + message: { content: Array<{ type: string; text?: string; name?: string }> }; + }; + return { step: data.step, content: data.message.content }; + }); +} + +function assertBalancedSteps(events: readonly WireEvent[]): void { + let open = 0; + for (const event of events) { + if (event.type === "step/start") { + assert.equal(open, 0, `step opened while another is open at seq ${String(event.seq)}`); + open += 1; + } + if (event.type === "step/end") { + assert.equal(open, 1, `step closed without an open step at seq ${String(event.seq)}`); + open -= 1; + } + } + assert.equal(open, 0); + assert.deepEqual( + events.map((event) => event.seq), + events.map((_, index) => index), + "seqs are dense", + ); +} + +describe("TurnProjector", () => { + it("keeps text a Harness appends to a message Item that spans a tool call (recorded Claude Code turn)", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: true }); + projector.begin([{ type: "text", text: "ask me" }], "request-1"); + const fixture = readFileSync( + join(import.meta.dirname, "fixtures/claude-question-spanning-tool.jsonl"), + "utf8", + ) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { kind: string; event?: Record & { type: string } }, + ); + for (const output of fixture) + if (output.kind === "event" && output.event !== undefined) feed(projector, output.event); + assert.ok(projector.isFinished); + const assistant = messages(log.events); + assert.deepEqual( + assistant.map((message) => message.content.map((part) => part.type)), + [["tool-call"], ["text"]], + ); + assert.equal(assistant[1]?.content[0]?.text, "Blue"); + assert.ok( + defined(assistant[1]).step > defined(assistant[0]).step, + "narration after the tool opens a later step", + ); + assertBalancedSteps(log.events); + assert.equal(log.events.at(-1)?.type, "turn/end"); + }); + + it("projects narrate → act → narrate as two steps with one tool pair", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: true, cwd: "/w" }); + projector.begin([{ type: "text", text: "list files" }], undefined); + projector.itemStarted({ type: "agentMessage", itemId: "m1", text: "" }); + projector.itemUpdated("m1", { type: "text.append", text: "Listing." }); + projector.itemCompleted( + { type: "agentMessage", itemId: "m1", text: "Listing." }, + { status: "succeeded" }, + ); + projector.itemStarted({ type: "commandExecution", itemId: "c1", command: "ls", cwd: "/w" }); + projector.itemUpdated("c1", { type: "output.append", text: "a\nb\n" }); + projector.itemCompleted( + { type: "commandExecution", itemId: "c1", command: "ls", cwd: "/w", exitCode: 2 }, + { status: "succeeded" }, + ); + projector.itemStarted({ type: "agentMessage", itemId: "m2", text: "Two files." }); + projector.itemCompleted( + { type: "agentMessage", itemId: "m2", text: "Two files." }, + { status: "succeeded" }, + ); + projector.finish({ status: "succeeded" }); + + const assistant = messages(log.events); + assert.deepEqual( + assistant.map((message) => message.step), + [1, 2], + ); + assert.deepEqual( + defined(assistant[0]).content.map((part) => part.type), + ["text", "tool-call"], + ); + const call = defined(log.events.find((event) => event.type === "tool/call")).data as { + name: string; + arguments: string; + }; + assert.equal(call.name, "bash"); + assert.deepEqual(JSON.parse(call.arguments), { command: "ls" }); + const result = defined(log.events.find((event) => event.type === "tool/result")); + const text = defined( + (result.data as { message: { content: Array<{ text: string }> } }).message.content[0], + ).text; + assert.equal(text, "a\nb\n\n[exit code: 2]"); + assert.deepEqual(result.sourceEventSeqs, [ + defined(log.events.find((event) => event.type === "tool/call")).seq, + ]); + assertBalancedSteps(log.events); + }); + + it("does not commit an empty assistant message for a failed attempt", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: true }); + projector.begin([{ type: "text", text: "hi" }], undefined); + projector.itemStarted({ type: "reasoning", itemId: "r1", text: "" }); + projector.finish({ + status: "failed", + error: { code: "nativeFailure", message: "auth expired" }, + }); + assert.equal(messages(log.events).length, 0); + const end = defined(log.events.at(-1)); + assert.equal(end.type, "turn/end"); + assert.deepEqual((end.data as { reason: unknown }).reason, { + kind: "error", + error: { message: "auth expired", code: "nativeFailure" }, + }); + assertBalancedSteps(log.events); + }); + + it("settles tools the Harness never completed when the turn ends", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: true }); + projector.begin([{ type: "text", text: "run" }], undefined); + projector.itemStarted({ type: "commandExecution", itemId: "c1", command: "sleep 100" }); + projector.finish({ status: "cancelled" }); + const result = defined(log.events.find((event) => event.type === "tool/result")); + assert.equal((result.data as { message: { isError: boolean } }).message.isError, true); + assert.deepEqual((defined(log.events.at(-1)).data as { reason: unknown }).reason, { + kind: "aborted", + reason: { kind: "user" }, + }); + }); + + it("maps Claude Code file tools onto DSH diff views", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: false }); + projector.begin([{ type: "text", text: "edit" }], undefined); + const edit: HostItem = { + type: "toolExecution", + itemId: "t1", + toolName: "Edit", + arguments: { file_path: "/w/a.ts", old_string: "a", new_string: "b" }, + }; + projector.itemCompleted(edit, { status: "succeeded" }); + projector.finish({ status: "succeeded" }); + const call = defined(log.events.find((event) => event.type === "tool/call")).data as { + name: string; + }; + assert.equal(call.name, "edit"); + const result = defined(log.events.find((event) => event.type === "tool/result")).data as { + meta: unknown; + }; + assert.deepEqual(result.meta, { diffs: [{ path: "/w/a.ts", oldText: "a", newText: "b" }] }); + }); + + it("replays history without presentation frames", () => { + const log = newLog(); + const frames: unknown[] = []; + log.follow( + { + id: "f", + closed: false, + push: (frame) => frames.push(frame), + end() {}, + fail() {}, + onClose() {}, + }, + {}, + ); + const projector = new TurnProjector(log, { live: false }); + projector.begin([{ type: "text", text: "q" }], undefined); + projector.itemCompleted( + { type: "agentMessage", itemId: "m", text: "answer" }, + { status: "succeeded" }, + ); + projector.finish({ status: "succeeded" }); + assert.equal( + frames.filter((frame) => (frame as { type: string }).type === "assistant-stream").length, + 0, + ); + assert.equal(messages(log.events)[0]?.content[0]?.text, "answer"); + }); +}); + +describe("tool normalization", () => { + it("normalizes Pi argument names", () => { + assert.deepEqual(normalizeTool("edit", { path: "x", oldText: "a", newText: "b" }), { + name: "edit", + args: { + path: "x", + oldText: "a", + newText: "b", + file_path: "x", + old_string: "a", + new_string: "b", + }, + }); + assert.deepEqual(normalizeTool("WebSearch", { query: "q" }), { + name: "web_search", + args: { query: "q", queries: ["q"] }, + }); + assert.deepEqual(normalizeTool("mcp__server__tool", { a: 1 }), { + name: "mcp__server__tool", + args: { a: 1 }, + }); + }); + + it("splits unified diffs into before/after texts", () => { + const texts = diffTexts({ + path: "f", + kind: "update", + unifiedDiff: "--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n keep\n-old\n+new", + }); + assert.deepEqual(texts, { path: "f", oldText: "keep\nold", newText: "keep\nnew" }); + assert.equal(diffTexts({ path: "n", kind: "add", unifiedDiff: "+x" }).oldText, null); + }); +}); + +describe("tool output cap", () => { + it("keeps head and tail of oversized tool output", async () => { + const { capText } = await import("../src/projector.ts"); + const big = `${"a".repeat(150_000)}END`; + const capped = capText(big); + assert.ok(capped.length < 100_200); + assert.ok(capped.startsWith("aaa")); + assert.ok(capped.endsWith("END")); + assert.match(capped, /characters truncated/u); + assert.equal(capText("short"), "short"); + }); +}); + +describe("Claude Code AskUserQuestion", () => { + it("renders through the DSH question card with synthesized ids and parsed answers", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: false }); + projector.begin([{ type: "text", text: "ask" }], undefined); + const item: HostItem = { + type: "toolExecution", + itemId: "q", + toolName: "AskUserQuestion", + arguments: { + questions: [ + { + question: "Which color do you prefer?", + header: "Color", + multiSelect: false, + options: [{ label: "Red" }, { label: "Blue" }], + }, + ], + }, + output: { + content: [ + { + type: "text", + text: 'Your questions have been answered: "Which color do you prefer?"="Blue". You can now continue.', + }, + ], + }, + }; + projector.itemCompleted(item, { status: "succeeded" }); + projector.finish({ status: "succeeded" }); + const call = defined(log.events.find((event) => event.type === "tool/call")).data as { + name: string; + arguments: string; + }; + assert.equal(call.name, "ask_user_question"); + assert.equal( + defined((JSON.parse(call.arguments) as { questions: Array<{ id: string }> }).questions[0]).id, + "q0", + ); + const result = defined(log.events.find((event) => event.type === "tool/result")).data as { + message: { content: Array<{ text: string }> }; + }; + assert.deepEqual(JSON.parse(defined(result.message.content[0]).text), { + answers: [{ id: "q0", selected: ["Blue"] }], + }); + }); +}); + +describe("orphaned turns", () => { + it("closes a turn a previous process left open", () => { + const log = newLog(); + const projector = new TurnProjector(log, { live: true }); + projector.begin([{ type: "text", text: "long task" }], undefined); + projector.itemStarted({ type: "agentMessage", itemId: "m", text: "partial" }); + // The process dies here: no finish(). + assert.equal(log.closeOrphanedTurn(), true); + const tail = log.events.slice(-2).map((event) => event.type); + assert.deepEqual(tail, ["step/end", "turn/end"]); + assert.deepEqual((defined(log.events.at(-1)).data as { reason: unknown }).reason, { + kind: "interrupted", + }); + assert.equal(log.closeOrphanedTurn(), false); + assertBalancedSteps(log.events); + }); +}); + +describe("live tool output", () => { + it("keeps output that arrives before the tool starts and reports settlement", () => { + const log = newLog(); + const outputs: Array<[string, string | undefined]> = []; + const projector = new TurnProjector(log, { + live: true, + onToolOutput: (callId, output) => outputs.push([callId, output]), + }); + projector.begin([{ type: "text", text: "run" }], undefined); + projector.itemUpdated("c1", { type: "output.append", text: "line 1\n" }); + projector.itemStarted({ type: "commandExecution", itemId: "c1", command: "seq 2" }); + projector.itemUpdated("c1", { type: "output.append", text: "line 2\n" }); + projector.itemCompleted( + { type: "commandExecution", itemId: "c1", command: "seq 2", exitCode: 0 }, + { status: "succeeded" }, + ); + assert.deepEqual(outputs, [ + ["c1", "line 1\n"], + ["c1", "line 1\nline 2\n"], + ["c1", undefined], + ]); + const result = defined(log.events.find((event) => event.type === "tool/result")).data as { + message: { content: Array<{ text: string }> }; + }; + assert.equal(defined(result.message.content[0]).text, "line 1\nline 2\n"); + }); +}); diff --git a/packages/web-server/test/sessions.integration.test.ts b/packages/web-server/test/sessions.integration.test.ts new file mode 100644 index 000000000..368c186dd --- /dev/null +++ b/packages/web-server/test/sessions.integration.test.ts @@ -0,0 +1,588 @@ +import assert from "node:assert/strict"; +import { defined } from "./support/defined.ts"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { HarnessRegistry } from "../src/harnesses.ts"; +import type { WireEvent } from "../src/session-log.ts"; +import { Sessions, type SessionNotification } from "../src/sessions.ts"; +import { DataDir } from "../src/store.ts"; +import { EventHub, RpcRegistry, StreamRegistry, type StreamSink } from "../src/transport.ts"; +import { Workspaces } from "../src/workspaces.ts"; + +interface Bench { + rpc: RpcRegistry; + streams: StreamRegistry; + sessions: Sessions; + events: EventHub; + data: DataDir; + frames: unknown[]; + notifications: SessionNotification[]; + cwd: string; + call(method: string, args: Record): Promise; +} + +let root = ""; +let bench: Bench; + +function sink(frames: unknown[]): StreamSink { + return { + id: "test", + closed: false, + push: (frame) => frames.push(frame), + end() {}, + fail() {}, + onClose() {}, + }; +} + +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 3000): Promise { + const until = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > until) throw new Error(`timed out waiting for ${label}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + +function journal(sessionId: string): WireEvent[] { + return bench.data.readLines(`sessions/${sessionId}/events.jsonl`); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "codexhost-sessions-")); + const cwd = join(root, "ws"); + mkdirSync(cwd); + const data = new DataDir(join(root, "data")); + const harnesses = new HarnessRegistry([resolve(import.meta.dirname, "fake-harness")]); + const workspaces = new Workspaces(data, cwd); + const events = new EventHub("/home/test"); + const sessions = new Sessions(data, harnesses, workspaces, events); + const rpc = new RpcRegistry(); + const streams = new StreamRegistry(); + workspaces.register(rpc, streams); + sessions.register(rpc, streams); + sessions.registerCommands(rpc); + sessions.registerImport(rpc); + rpc.register("$events/result", (args) => events.settle(args as never)); + const frames: unknown[] = []; + events.handler({}, sink(frames)); + const notifications: SessionNotification[] = []; + sessions.notifier = (notification) => notifications.push(notification); + globalThis.fakeHarnessScripts = {}; + globalThis.fakeHarnessLog = []; + bench = { + rpc, + streams, + sessions, + events, + data, + frames, + notifications, + cwd, + async call(method: string, args: Record): Promise { + const result = await rpc.dispatch(method, { args }); + if (!result.ok) throw new Error(`${method}: ${result.error.code} ${result.error.message}`); + return result.value as T; + }, + }; +}); + +afterEach(async () => { + await bench.sessions.close(); + rmSync(root, { recursive: true, force: true }); +}); + +async function newSession(): Promise { + const { sessionId } = await bench.call<{ sessionId: string }>("session/create", { + request: { cwd: bench.cwd }, + }); + return sessionId; +} + +async function prompt( + sessionId: string, + text: string, + mode: "queue" | "steer" = "queue", +): Promise { + await bench.call("session/prompt", { + request: { requestId: `r-${text}`, sessionId, mode, content: [{ type: "text", text }] }, + }); +} + +function turnEnds(sessionId: string): WireEvent[] { + return journal(sessionId).filter((event) => event.type === "turn/end"); +} + +describe("Sessions with a scripted Harness", () => { + it("runs a turn end to end and notifies with the reply", async () => { + const sessionId = await newSession(); + await prompt(sessionId, "hello"); + await waitFor(() => turnEnds(sessionId).length === 1, "turn end"); + const events = journal(sessionId); + assert.deepEqual( + events.map((event) => event.type).filter((type) => !type.startsWith("session/")), + ["turn/start", "step/start", "user/message", "assistant/message", "step/end", "turn/end"], + ); + const reply = defined(events.find((event) => event.type === "assistant/message")).data as { + message: { content: Array<{ text: string }> }; + }; + assert.equal(defined(reply.message.content[0]).text, "echo: hello"); + assert.equal(bench.notifications.at(-1)?.kind, "turn"); + assert.equal(bench.notifications.at(-1)?.body, "echo: hello"); + const list = await bench.call<{ items: Array<{ sessionId: string; blank: boolean }> }>( + "session/list", + {}, + ); + assert.equal(list.items.find((item) => item.sessionId === sessionId)?.blank, false); + }); + + it("routes an approval through the event hub and resumes the turn", async () => { + defined(globalThis.fakeHarnessScripts).approve = [ + { + interaction: { + type: "approval", + interactionId: "ap-1", + title: "Run rm -rf build", + subject: { type: "nativeAction" }, + actions: [ + { id: "yes", label: "Allow", effect: "allowOnce" }, + { id: "no", label: "Deny", effect: "deny" }, + ], + }, + }, + { awaitResponse: "ap-1" }, + { event: { type: "turn.completed", outcome: { status: "succeeded" } } }, + ]; + const sessionId = await newSession(); + await prompt(sessionId, "approve"); + await waitFor( + () => bench.frames.some((frame) => (frame as { type?: string }).type === "waterfall"), + "approval waterfall", + ); + const waterfall = bench.frames.find( + (frame) => (frame as { type?: string }).type === "waterfall", + ) as { eventId: string; event: string; agentId: string; request: { toolName: string } }; + assert.equal(waterfall.event, "approval/request"); + assert.equal(waterfall.agentId, sessionId); + assert.equal(waterfall.request.toolName, "Run rm -rf build"); + assert.equal(bench.notifications.at(-1)?.kind, "approval"); + await bench.call("$events/result", { + clientId: "c", + eventId: waterfall.eventId, + outcome: { kind: "result", value: "allowed-once" }, + }); + await waitFor(() => turnEnds(sessionId).length === 1, "turn end after approval"); + assert.deepEqual( + defined(globalThis.fakeHarnessLog).find((entry) => entry.responded === "ap-1")?.response, + { type: "approval", actionId: "yes" }, + ); + }); + + it("maps question answers back to option values", async () => { + defined(globalThis.fakeHarnessScripts).ask = [ + { + interaction: { + type: "question", + interactionId: "q-1", + questions: [ + { + id: "color", + type: "choice", + prompt: "Color?", + options: [ + { value: "r", label: "Red" }, + { value: "b", label: "Blue" }, + ], + multiple: false, + allowOther: true, + optional: false, + }, + ], + }, + }, + { awaitResponse: "q-1" }, + { event: { type: "turn.completed", outcome: { status: "succeeded" } } }, + ]; + const sessionId = await newSession(); + await prompt(sessionId, "ask"); + await waitFor( + () => + bench.frames.some( + (frame) => (frame as { event?: string }).event === "user-questions/request", + ), + "question waterfall", + ); + const waterfall = bench.frames.find( + (frame) => (frame as { event?: string }).event === "user-questions/request", + ) as { eventId: string }; + await bench.call("$events/result", { + clientId: "c", + eventId: waterfall.eventId, + outcome: { + kind: "result", + value: { answers: [{ id: "color", selected: ["Blue"], custom: "teal" }] }, + }, + }); + await waitFor(() => turnEnds(sessionId).length === 1, "turn end after answer"); + assert.deepEqual( + defined(globalThis.fakeHarnessLog).find((entry) => entry.responded === "q-1")?.response, + { type: "question", answers: { color: ["b", "teal"] } }, + ); + }); + + it("queues prompts sent while a turn runs and starts them in order", async () => { + defined(globalThis.fakeHarnessScripts).slow = [{ awaitCancel: true }]; + const sessionId = await newSession(); + await prompt(sessionId, "slow"); + await waitFor( + () => journal(sessionId).some((event) => event.type === "user/message"), + "first turn", + ); + await prompt(sessionId, "second"); + const runtimeInbox = () => + ( + bench.data.readJson>(`sessions/${sessionId}/projections.json`, {}) + .inbox as { "next-turn": unknown[] } + )["next-turn"]; + assert.equal(runtimeInbox().length, 1); + await bench.call("session/cancel", { request: { sessionId } }); + await waitFor(() => turnEnds(sessionId).length === 1, "cancelled turn"); + // Cancel clears the queue: the queued prompt does not start. + await new Promise((r) => setTimeout(r, 50)); + assert.equal(turnEnds(sessionId).length, 1); + assert.deepEqual((defined(turnEnds(sessionId)[0]).data as { reason: unknown }).reason, { + kind: "aborted", + reason: { kind: "user" }, + }); + }); + + it("steers by cancelling and starting the new input next", async () => { + defined(globalThis.fakeHarnessScripts).slow = [{ awaitCancel: true }]; + const sessionId = await newSession(); + await prompt(sessionId, "slow"); + await waitFor( + () => journal(sessionId).some((event) => event.type === "user/message"), + "first turn", + ); + await prompt(sessionId, "redirect", "steer"); + await waitFor(() => turnEnds(sessionId).length === 2, "steered turn"); + const users = journal(sessionId) + .filter((event) => event.type === "user/message") + .map( + (event) => defined((event.data as { content: Array<{ text: string }> }).content[0]).text, + ); + assert.deepEqual(users, ["slow", "redirect"]); + assert.deepEqual( + defined(globalThis.fakeHarnessLog) + .filter((entry) => entry.command !== undefined) + .map((entry) => entry.command), + ["turn.start", "turn.cancel", "turn.start"], + ); + }); + + it("switches permission mode through the slash command and keeps one Harness per session", async () => { + const sessionId = await newSession(); + const modes = await bench.call<{ options: Array<{ value: string }> }>( + "permissionPresets/catalog", + { sessionId }, + ); + assert.deepEqual( + modes.options.map((option) => option.value), + ["ask", "yolo"], + ); + const result = await bench.call<{ result: { kind: string } }>("commands/execute", { + agentId: sessionId, + line: "/permission yolo", + submittedAttachments: [], + }); + assert.equal(result.result.kind, "success"); + const bad = await bench.call<{ result: { kind: string } }>("commands/execute", { + agentId: sessionId, + line: "/permission nope", + submittedAttachments: [], + }); + assert.equal(bad.result.kind, "error"); + await prompt(sessionId, "hi"); + await waitFor(() => turnEnds(sessionId).length === 1, "turn"); + const rejected = await bench.rpc.dispatch("session/selectModel", { + args: { request: { sessionId, provider: "other", model: "x" } }, + }); + assert.equal(rejected.ok, false); + }); + + it("rejects attachments instead of dropping them", async () => { + const sessionId = await newSession(); + const result = await bench.rpc.dispatch("session/prompt", { + args: { + request: { + requestId: "i", + sessionId, + mode: "queue", + content: [{ type: "image", mediaType: "image/png", data: "AAAA" }], + }, + }, + }); + assert.equal(result.ok, false); + }); +}); + +describe("fork", () => { + it("forks at a completed turn with its checkpoint and copies the journal prefix", async () => { + defined(globalThis.fakeHarnessScripts).one = [ + { + event: { + type: "item.completed", + snapshot: { + item: { type: "agentMessage", itemId: "a1", text: "first" }, + outcome: { status: "succeeded" }, + }, + }, + }, + { + event: { + type: "turn.completed", + outcome: { status: "succeeded", checkpoint: { harnessId: "fake", key: "cp-1" } }, + }, + }, + ]; + defined(globalThis.fakeHarnessScripts).two = [ + { + event: { + type: "item.completed", + snapshot: { + item: { type: "agentMessage", itemId: "a2", text: "second" }, + outcome: { status: "succeeded" }, + }, + }, + }, + { + event: { + type: "turn.completed", + outcome: { status: "succeeded", checkpoint: { harnessId: "fake", key: "cp-2" } }, + }, + }, + ]; + const sessionId = await newSession(); + await prompt(sessionId, "one"); + await waitFor(() => turnEnds(sessionId).length === 1, "turn 1"); + await prompt(sessionId, "two"); + await waitFor(() => turnEnds(sessionId).length === 2, "turn 2"); + const firstEnd = defined(turnEnds(sessionId)[0]); + const { sessionId: forkId } = await bench.call<{ sessionId: string }>("session/fork", { + request: { sessionId, atSeq: firstEnd.seq }, + }); + assert.deepEqual( + defined(globalThis.fakeHarnessLog) + .filter((entry) => entry.open !== undefined) + .map((entry) => entry.open), + ["create", "fork"], + ); + const forked = journal(forkId); + assert.equal(forked.length, firstEnd.seq + 1); + assert.equal(defined(forked.at(-1)).type, "turn/end"); + const list = await bench.call<{ + items: Array<{ sessionId: string; projections: { values: { title: string } } }>; + }>("session/list", {}); + assert.match( + defined(list.items.find((item) => item.sessionId === forkId)).projections.values.title, + /^Fork · /u, + ); + await prompt(forkId, "after fork"); + await waitFor(() => turnEnds(forkId).length === 2, "fork turn"); + assert.equal( + ( + defined( + journal(forkId) + .filter((event) => event.type === "turn/start") + .at(-1), + ).data as { turn: number } + ).turn, + 2, + ); + }); +}); + +function followSnapshot(sessionId: string): Promise<{ records: Array<{ event: WireEvent }> }> { + return new Promise((resolve) => { + const frames: unknown[] = []; + const capture: StreamSink = { + id: "follow", + closed: false, + push: (frame) => { + frames.push(frame); + if ((frame as { type?: string }).type === "snapshot") + resolve(frame as { records: Array<{ event: WireEvent }> }); + }, + end() {}, + fail() {}, + onClose() {}, + }; + const handler = defined( + ( + bench.streams as unknown as { + handlers: Map void>; + } + ).handlers.get("session/follow"), + ); + handler( + { request: { address: { kind: "session", sessionId }, assistantStream: true } }, + capture, + ); + }); +} + +const historyTurn = (prompt: string, reply: string, startedAtMs: number) => ({ + nativeTurnRef: { harnessId: "fake", nativeSessionId: "h", nativeTurnKey: prompt }, + input: [{ type: "text", text: prompt }], + items: [ + { + item: { + type: "commandExecution", + itemId: `c-${prompt}`, + command: "ls", + output: "a\n", + exitCode: 0, + }, + outcome: { status: "succeeded" }, + }, + { + item: { type: "agentMessage", itemId: `m-${prompt}`, text: reply }, + outcome: { status: "succeeded" }, + }, + ], + outcome: { status: "succeeded" }, + startedAtMs, + completedAtMs: startedAtMs + 31_000, + checkpoint: { key: `cp-${prompt}` }, +}); + +describe("native session import", () => { + it("imports once, replays history with native timestamps on first open, and resumes", async () => { + process.env.FAKE_HARNESS_CWD = bench.cwd; + globalThis.fakeHarnessHistory = { + "native-1": [ + historyTurn("first", "one", 1_700_000_000_000), + historyTurn("second", "two", 1_700_000_100_000), + ], + }; + const candidates = await bench.call<{ + items: Array<{ nativeSessionId: string; imported?: string }>; + }>("codexhost/importCandidates", { request: { harnessId: "fake" } }); + assert.deepEqual( + candidates.items.map((item) => item.nativeSessionId), + ["native-1"], + ); + const first = await bench.call<{ sessionId: string; created: boolean }>("codexhost/import", { + request: { harnessId: "fake", nativeSessionId: "native-1" }, + }); + const again = await bench.call<{ sessionId: string; created: boolean }>("codexhost/import", { + request: { harnessId: "fake", nativeSessionId: "native-1" }, + }); + assert.equal(first.created, true); + assert.deepEqual(again, { sessionId: first.sessionId, created: false }); + const snapshot = await followSnapshot(first.sessionId); + const types = snapshot.records.map((record) => record.event.type); + assert.equal(types.filter((type) => type === "turn/start").length, 2); + assert.equal(types.filter((type) => type === "tool/call").length, 2); + const turnStart = defined( + snapshot.records.find((record) => record.event.type === "turn/start"), + ).event; + const turnEnd = defined( + snapshot.records.find((record) => record.event.type === "turn/end"), + ).event; + assert.equal(turnStart.time, 1_700_000_000_000); + assert.equal(turnEnd.time - turnStart.time, 31_000); + // History reads open the native session for resume, and later prompts continue it. + await prompt(first.sessionId, "third"); + await waitFor(() => turnEnds(first.sessionId).length === 3, "resumed turn"); + assert.ok(defined(globalThis.fakeHarnessLog).some((entry) => entry.open === "resume")); + const meta = bench.data.readJson }>>( + "sessions/index.json", + {}, + )[first.sessionId]; + assert.deepEqual(Object.keys(meta?.checkpoints ?? {}).sort(), ["1", "2"]); + }); +}); + +describe("subagents", () => { + it("lists delegated subagents in the parent catalog and replays the child transcript", async () => { + globalThis.fakeHarnessSubagents = { + "sub-native-1": [historyTurn("child task", "child done", 1_700_000_000_000)], + }; + defined(globalThis.fakeHarnessScripts).delegate = [ + { + event: { + type: "item.started", + item: { + type: "subagentDelegation", + itemId: "d1", + operation: "spawn", + prompt: "do it", + subagents: [ + { + subagentId: "s1", + nativeSubagentId: "sub-native-1", + description: "Child worker", + background: false, + status: "running", + }, + ], + }, + }, + }, + { + event: { + type: "item.completed", + snapshot: { + item: { + type: "subagentDelegation", + itemId: "d1", + operation: "spawn", + prompt: "do it", + subagents: [ + { + subagentId: "s1", + nativeSubagentId: "sub-native-1", + description: "Child worker", + background: false, + status: "completed", + resultSummary: "ok", + }, + ], + }, + outcome: { status: "succeeded" }, + }, + }, + }, + { event: { type: "turn.completed", outcome: { status: "succeeded" } } }, + ]; + const sessionId = await newSession(); + await prompt(sessionId, "delegate"); + await waitFor(() => turnEnds(sessionId).length === 1, "delegation turn"); + const catalog = bench.data.readJson>( + `sessions/${sessionId}/projections.json`, + {}, + ).subagentCatalog as Array<{ id: string; label: string; mode: string }>; + assert.equal(catalog.length, 1); + assert.equal(defined(catalog[0]).label, "Child worker"); + const list = await bench.call<{ + items: Array<{ sessionId: string; origin?: string; parentSessionId?: string }>; + }>("session/list", {}); + const child = defined(list.items.find((item) => item.sessionId === defined(catalog[0]).id)); + assert.equal(child.origin, "subagent"); + assert.equal(child.parentSessionId, sessionId); + const snapshot = await followSnapshot(child.sessionId); + const texts = snapshot.records + .filter((record) => record.event.type === "assistant/message") + .flatMap( + (record) => + (record.event.data as { message: { content: Array<{ type: string; text?: string }> } }) + .message.content, + ) + .filter((part) => part.type === "text") + .map((part) => part.text); + assert.deepEqual(texts, ["child done"]); + }); +}); diff --git a/packages/web-server/test/support/defined.ts b/packages/web-server/test/support/defined.ts new file mode 100644 index 000000000..cd6125177 --- /dev/null +++ b/packages/web-server/test/support/defined.ts @@ -0,0 +1,13 @@ +/** Fail a fixture lookup at its assertion site instead of asserting away absence. */ +import assert from "node:assert/strict"; + +/** + * Require a value supplied by a test fixture. + * @param value Fixture element or lookup result. + * @returns The non-null fixture value. + */ +export function defined(value: T): NonNullable { + assert.notEqual(value, undefined); + assert.notEqual(value, null); + return value as NonNullable; +} diff --git a/packages/web-server/test/support/server.ts b/packages/web-server/test/support/server.ts new file mode 100644 index 000000000..44f17dcbe --- /dev/null +++ b/packages/web-server/test/support/server.ts @@ -0,0 +1,89 @@ +/** Isolated server process shared by browser and packed-distribution checks. */ +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import type { TestContext } from "node:test"; + +/** + * Start the real entry point on an OS-assigned port with disposable data and workspace directories. + * @param t - test owner of the child process and its data. + * @param cwd - server working directory. + * @param entry - Node arguments preceding server options (source or packed entry). + * @param args - additional server options. + * @returns the listening URL. + */ +export async function startServer( + t: TestContext, + cwd: string, + entry: string[], + args: string[], +): Promise { + const data = mkdtempSync(join(tmpdir(), "codexhost-web-test-")); + const child = spawn( + process.execPath, + [ + ...entry, + "--host", + "127.0.0.1", + "--port", + "0", + "--data", + data, + "--workspace", + join(data, "workspace"), + "--no-auth", + ...args, + ], + { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += String(chunk); + }); + child.stderr.on("data", (chunk) => { + output += String(chunk); + }); + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + t.after(async () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + }, 5000); + await exited; + clearTimeout(timeout); + } + rmSync(data, { recursive: true, force: true }); + }); + return await new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stdout }); + const timeout = setTimeout( + () => reject(new Error(`Server startup timed out: ${output}`)), + 15_000, + ); + const finish = (): void => { + clearTimeout(timeout); + lines.close(); + }; + lines.on("line", (line) => { + const match = /^codexhost web: (http:\/\/\S+)/u.exec(line); + if (match !== null) { + finish(); + resolve(match[1] as string); + } + }); + child.once("error", (error) => { + finish(); + reject(error); + }); + child.once("exit", () => { + finish(); + reject(new Error(`Server exited before listening: ${output}`)); + }); + }); +} diff --git a/packages/web-server/tsconfig.json b/packages/web-server/tsconfig.json new file mode 100644 index 000000000..362a4c2ae --- /dev/null +++ b/packages/web-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "rootDir": "src", + "outDir": "dist", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "references": [ + { "path": "../harness-adapter/tsconfig.json" }, + { "path": "../harness-plugin-files/tsconfig.json" }, + { "path": "../shared-contracts/tsconfig.json" } + ], + "include": ["src/**/*.ts"] +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 96567c7d9..fd5b76573 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "noEmit": true, + "allowImportingTsExtensions": true, "declaration": false, "declarationMap": false, "sourceMap": false, diff --git a/tests/vitest.config.js b/tests/vitest.config.js index 9c5d13044..e8d85b18b 100644 --- a/tests/vitest.config.js +++ b/tests/vitest.config.js @@ -35,9 +35,12 @@ export default defineConfig({ test: { environment: "node", include, - ...(platformOnly - ? { exclude: [...configDefaults.exclude, ...platformIndependentFiles()] } - : {}), + // The Web Host owns Node test-runner suites, invoked by npm run test:web. + exclude: [ + ...configDefaults.exclude, + "packages/web-server/test/**", + ...(platformOnly ? platformIndependentFiles() : []), + ], maxWorkers: 4, passWithNoTests: false, // Hosted Windows runners have highly variable disk latency (fsync + rename diff --git a/tsconfig.json b/tsconfig.json index d06c4a626..60123b164 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,9 @@ { "path": "./packages/adapters/kimi-code/tsconfig.json" }, { "path": "./packages/adapters/zcode/tsconfig.json" }, { "path": "./packages/adapters/codex-usage/tsconfig.json" }, + { "path": "./packages/adapters/codex/tsconfig.json" }, { "path": "./packages/host-runtime/tsconfig.json" }, + { "path": "./packages/web-server/tsconfig.json" }, { "path": "./packages/renderer-extension/tsconfig.json" } ] } From 664c4301419ca9c544a3e0bd7c0a579462ac9da8 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Fri, 9 Oct 2026 21:59:21 +0800 Subject: [PATCH 002/106] feat: expose native harness controls in standalone web Add a dedicated Harness picker, session-specific native permissions and sidebar brand icons. Preserve native mode descriptions, risk flags and creation scope, and surface rejected switches. Support isolated multi-Harness previews while continuing to exclude Pi and Codex. Cover desktop/mobile controls, persistence and distribution resources with focused tests. --- .../src/client/SessionHarnessIcon.tsx | 51 ++++ .../client/ui-codexhost/src/client/index.ts | 15 +- .../src/client/HarnessSelect.module.css | 71 +++++ .../src/client/HarnessSelect.tsx | 171 ++++++++++++ .../src/client/ModelControls.tsx | 18 ++ .../src/client/ModelSelect.tsx | 34 +-- .../src/client/directory.ts | 28 +- .../ui-model-selection/src/client/index.ts | 10 +- .../ui-model-selection/src/client/locales.ts | 11 + .../src/client/PermissionSelect.module.css | 20 ++ .../src/client/PermissionSelect.tsx | 253 ++++++------------ .../ui-permission-presets/src/client/index.ts | 240 ++++------------- .../src/client/locales.ts | 12 + .../src/client/native.ts | 22 ++ .../ui-workspace/src/client/contract/slots.ts | 7 + .../client/ui-workspace/src/client/index.ts | 1 + .../src/client/rows/Rows.module.css | 15 ++ .../ui-workspace/src/client/rows/Rows.tsx | 19 +- .../src/client/rows/WorkspaceBrowser.tsx | 1 + docs/product/standalone-web.md | 18 +- packages/web-server/README.md | 2 +- packages/web-server/scripts/pack.ts | 8 +- packages/web-server/scripts/preview.ts | 52 +++- packages/web-server/src/harnesses.ts | 5 +- packages/web-server/src/main.ts | 4 +- packages/web-server/src/preview-launch.ts | 18 +- .../web-server/src/session-presentation.ts | 40 +++ packages/web-server/src/sessions.ts | 120 ++++++--- packages/web-server/src/web-assets.ts | 6 +- .../test/browser/native-presentation.test.ts | 132 +++++++++ packages/web-server/test/browser/web.test.ts | 84 ++++++ .../web-server/test/distribution/pack.test.ts | 57 +++- .../test/fake-harness/fake/icon.svg | 1 + .../test/fake-harness/fake/manifest.json | 3 +- .../test/fake-harness/fake/plugin.ts | 92 +++++-- .../test/fake-harness/other/icon.svg | 1 + .../test/fake-harness/other/manifest.json | 6 + .../test/fake-harness/other/plugin.ts | 30 +++ .../web-server/test/preview-launch.test.ts | 37 ++- .../test/session-presentation.test.ts | 62 +++++ .../test/sessions.integration.test.ts | 44 ++- 41 files changed, 1347 insertions(+), 474 deletions(-) create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.module.css create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx create mode 100644 apps/web-ui/packages/client/ui-model-selection/src/client/ModelControls.tsx create mode 100644 apps/web-ui/packages/client/ui-permission-presets/src/client/native.ts create mode 100644 packages/web-server/src/session-presentation.ts create mode 100644 packages/web-server/test/browser/native-presentation.test.ts create mode 100644 packages/web-server/test/fake-harness/fake/icon.svg create mode 100644 packages/web-server/test/fake-harness/other/icon.svg create mode 100644 packages/web-server/test/fake-harness/other/manifest.json create mode 100644 packages/web-server/test/fake-harness/other/plugin.ts create mode 100644 packages/web-server/test/session-presentation.test.ts diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx b/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx new file mode 100644 index 000000000..ed048435a --- /dev/null +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx @@ -0,0 +1,51 @@ +/** Sidebar branding reads durable per-session metadata without retaining/opening history. */ +import { useState, useSyncExternalStore } from "react"; +import type { SessionListState } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { IconDataOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; + +declare module "@deepseek-ai/dsh-session-projection/types" { + interface SessionProjectionMap { + /** The bound Harness, or this draft's explicitly selected Harness; never another session's default. */ + harnessIdentity: { id: string; name: string } | null; + } +} + +export function SessionHarnessIcon({ + sessionId, + list, +}: { + sessionId: SessionId; + list: HostObservable; +}) { + const identity = useSyncExternalStore( + (fn) => list.subscribe(fn), + () => list.getSnapshot().byId[sessionId]?.projectionValues?.harnessIdentity, + ); + const [failedId, setFailedId] = useState(null); + if (!identity) return null; + return ( + + {failedId === identity.id ? ( + + ) : ( + setFailedId(identity.id)} + /> + )} + + ); +} diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts index 6689d8424..a2ea64df2 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts @@ -12,6 +12,8 @@ import type {} from "@deepseek-ai/dsh-client-ui-workspace/client"; import type { SessionId } from "@deepseek-ai/dsh-session/types"; import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; import { ImportIcon } from "./ImportIcon.tsx"; +import type {} from "@deepseek-ai/dsh-api-session-controller/client"; +import { SessionHarnessIcon } from "./SessionHarnessIcon.tsx"; import { ensureServiceWorker } from "./notifications.ts"; import { NotificationsRow } from "./NotificationsRow.tsx"; import { ImportPage, type ImportPageFace } from "./ImportPage.tsx"; @@ -31,7 +33,7 @@ export const NS = "codexhostImport"; export const PANEL_ID = "codexhost-import" as MainPanelId; /** Services used by the panel. */ -export const inject = ["slots", "locale", "layout", "uiWorkspace"]; +export const inject = ["slots", "locale", "layout", "uiWorkspace", "sessions"]; /** * Contribute the sidebar entry and its main-column page. @@ -80,6 +82,17 @@ export function apply(ctx: ClientContext): void { ), ); + ctx.slots.inject("sidebar.session.row.identity", () => + ctx.slots.register( + { + name: "sidebar.session.row.identity", + id: "codexhost.harness", + inject: () => ({ list: ctx.sessions.list }), + }, + SessionHarnessIcon, + ), + ); + // Deep links (`?session=`, used by notification clicks) and service-worker messages. ensureServiceWorker(); const openFromUrl = (): void => { diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.module.css b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.module.css new file mode 100644 index 000000000..d09604411 --- /dev/null +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.module.css @@ -0,0 +1,71 @@ +.controls { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.trigger { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 4px; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.trigger:hover:not(:disabled) { + background: var(--dsw-alias-bg-module-platform); +} + +.trigger:focus-visible { + outline: 2px solid var(--dsw-focus-ring-color); + outline-offset: 2px; +} + +.trigger:disabled { + opacity: 0.5; + cursor: default; +} + +.icon { + display: block; + width: 20px; + height: 20px; + flex-shrink: 0; + object-fit: contain; + border-radius: 3px; +} + +.menu[role="menu"] { + width: 248px; + min-width: min(220px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + max-height: min(520px, calc(100dvh - 48px)); +} + +.menu [role="menuitem"] { + min-height: 44px; + gap: 10px; +} + +.menu [role="menuitem"]:has([data-current]) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.menu [role="presentation"] { + white-space: normal; +} + +@media (max-width: 600px) { + .trigger { + width: 40px; + height: 44px; + } +} diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx new file mode 100644 index 000000000..da5cbc3c0 --- /dev/null +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx @@ -0,0 +1,171 @@ +/** Native Harness identity picker beside the independent model/effort control. */ +import { useRef, useState, useSyncExternalStore } from "react"; +import { + IconDataOutlineRegular, + IconWarningOutlineRegular, + Menu, + type MenuEntry, + StateDot, + Toast, +} from "@deepseek-ai/dsh-client-ui-primitives"; +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ModelSelectInjected } from "./slots.ts"; +import css from "./HarnessSelect.module.css"; + +/** Show the plugin's own brand asset, with a generic glyph when it has no icon. */ +function HarnessIcon({ id }: { id: string }) { + const [failed, setFailed] = useState(false); + return failed ? ( + + ) : ( + setFailed(true)} + /> + ); +} + +/** Select through the same per-session directory and confirmed projection as the model menu. */ +export function HarnessSelect({ + available, + directory, + load, + select, + locked, + t, +}: ModelSelectInjected & { locked: boolean } & PropsLocale<"model">) { + const state = useSyncExternalStore( + (fn) => directory.subscribe(fn), + () => directory.getSnapshot(), + ); + const [open, setOpen] = useState(false); + const [toast, setToast] = useState<{ text: string; seq: number } | null>(null); + const trigger = useRef(null); + const currentId = state.current?.provider; + const currentName = + state.harnesses.find((group) => group.id === currentId)?.name ?? + state.failures.find((failure) => failure.id === currentId)?.name ?? + currentId; + const busy = state.pending !== null; + const footer: MenuEntry[] = []; + if (state.boundHarnessId !== null) + footer.push({ type: "label", id: "bound", text: t("harness.bound") }); + if (state.status === "loading") + footer.push({ type: "label", id: "loading", text: t("trigger.loading") }); + if (state.error !== null) footer.push({ type: "label", id: "error", text: state.error }); + if (state.harnesses.length === 0 && state.status !== "loading") + footer.push({ type: "label", id: "empty", text: t("harness.empty") }); + const close = () => setOpen(false); + const report = (text: string) => + setToast((previous) => ({ text, seq: (previous?.seq ?? 0) + 1 })); + + if (!available) return null; + + const choose = async (id: string): Promise => { + if (locked || busy || (state.boundHarnessId !== null && id !== state.boundHarnessId)) return; + if (id === currentId) { + close(); + return; + } + const model = state.harnesses.find((group) => group.id === id)?.models[0]; + if (model === undefined) return; + close(); + try { + const result = await select({ + provider: id, + model: model.id, + ...(model.reasoning?.defaultEffort === undefined + ? {} + : { reasoningEffort: model.reasoning.defaultEffort }), + }); + if (result !== undefined && !result.ok) + report(t("error.action", { message: result.error.message })); + } catch (error) { + report( + t("error.action", { message: error instanceof Error ? error.message : String(error) }), + ); + } + }; + + return ( + <> + ({ + id: group.id, + label: {group.name}, + icon: , + disabled: + locked || + busy || + group.models.length === 0 || + (state.boundHarnessId !== null && group.id !== state.boundHarnessId), + })), + ...state.failures.map((failure) => ({ + id: failure.id, + label: ( + + {failure.name} · {t("harness.unavailable")} + + ), + icon: , + disabled: true, + })), + ]} + onSelect={(id) => { + void choose(id); + }} + onClose={close} + footer={footer} + anchor={ + + } + /> + {toast !== null && ( + } + anchor={trigger.current?.closest("[data-composer-card]") ?? null} + onDone={() => setToast(null)} + /> + )} + + ); +} diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/ModelControls.tsx b/apps/web-ui/packages/client/ui-model-selection/src/client/ModelControls.tsx new file mode 100644 index 000000000..4a36df7df --- /dev/null +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/ModelControls.tsx @@ -0,0 +1,18 @@ +/** One composer seat with separate model/effort and native Harness controls. */ +import type { PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { ModelSelectInjected } from "./slots.ts"; +import { HarnessSelect } from "./HarnessSelect.tsx"; +import { ModelSelect } from "./ModelSelect.tsx"; +import css from "./HarnessSelect.module.css"; + +/** Share the existing per-session directory instead of introducing a second selected Harness state. */ +export function ModelControls( + props: ModelSelectInjected & { locked: boolean } & PropsLocale<"model">, +) { + return ( +
+ + +
+ ); +} diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.tsx index b1a9abc54..d423f6918 100644 --- a/apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -2,7 +2,7 @@ * ModelSelect: the composer's named model seat (`conversation.input.model`). * Two-level selection per figma 496:26454's MenuDropdown: the root menu is * the Model / Effort row pair (label + current value + a right chevron), - * each drilling into its own list — the provider-grouped model list over + * each drilling into its own list — the current Harness's model list over * the shared directory, and the effort levels. The trigger (313:14108's * ToggleButton) shows both: model name + effort in the caption tone. * Model catalogs above four entries show search, which retains focus while @@ -658,20 +658,24 @@ export function ModelSelect({ )} - {state.failures.map((failure) => ( -
- - {t("warning.groupLoad", { - name: - failure.id === "deepseek-account" ? t("provider.account") : failure.name, - message: failure.message, - })} - - -
- ))} + {state.failures + .filter((failure) => failure.id === state.current?.provider) + .map((failure) => ( +
+ + {t("warning.groupLoad", { + name: + failure.id === "deepseek-account" + ? t("provider.account") + : failure.name, + message: failure.message, + })} + + +
+ ))}
group.id === (boundHarnessId ?? intended?.provider)); const reasoning = intended === undefined ? undefined : this.catalog.reasoningFor(intended); const effort = intended?.reasoningEffort ?? reasoning?.defaultEffort; const retainedEffort = @@ -203,7 +212,9 @@ export class ModelDirectory { current: catalog.value === null ? null : this.store.getSnapshot().current, ...(retainedEffort === undefined ? {} : { retainedEffort }), routable: null, - groups: catalog.value?.groups ?? [], + groups, + harnesses, + boundHarnessId, failures: catalog.value?.failures ?? [], status: catalog.status === "error" ? "error" : "loading", pending: this.store.getSnapshot().pending, @@ -212,13 +223,8 @@ export class ModelDirectory { return; } const selection = projected.next ?? catalog.value.default; - // CodexHost: providers are Harnesses, and a Session that already ran on one Harness cannot - // move its native session to another, so the picker offers only that Harness's models. - const lockedProvider = projected.lastUsed?.provider; - const groups = - lockedProvider === undefined - ? catalog.value.groups - : catalog.value.groups.filter((group) => group.id === lockedProvider); + // Provider is the legacy wire field for Harness identity. Native history fixes it; + // before the first Turn, the Harness picker may change it through the same selection RPC. const routable = groups.some( (group) => group.id === selection.provider && @@ -229,7 +235,9 @@ export class ModelDirectory { ...(retainedEffort === undefined ? {} : { retainedEffort }), routable, groups, - failures: lockedProvider === undefined ? catalog.value.failures : [], + harnesses, + boundHarnessId, + failures: catalog.value.failures, status: this.store.getSnapshot().status === "selecting" ? "selecting" : "ready", pending: this.store.getSnapshot().pending, error: null, diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/index.ts b/apps/web-ui/packages/client/ui-model-selection/src/client/index.ts index 1563e3d3f..ab4fd6172 100644 --- a/apps/web-ui/packages/client/ui-model-selection/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/index.ts @@ -1,5 +1,5 @@ /** - * Model selection plugin, browser half — TWO entries over ONE per-session + * Harness/model selection plugin, browser half — composer controls and /model over ONE per-session * directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect * contribution and the composer's named `conversation.input.model` seat share * one Host-generation `session/modelCatalog` catalog, combine it with the Session's @@ -26,7 +26,7 @@ import { IconDataOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; import type { ModelDirectoryState } from "./directory.ts"; import { ModelDirectoryResolver } from "./service.ts"; import type { ModelSelectInjected } from "./slots.ts"; -import { ModelSelect } from "./ModelSelect.tsx"; +import { ModelControls } from "./ModelControls.tsx"; import { en, zh, type ModelKey } from "./locales.ts"; import { orderModelProviders } from "./provider-order.ts"; @@ -66,7 +66,9 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<"model">): Sel }); } } - for (const failure of directory.failures) { + for (const failure of directory.failures.filter( + (item) => item.id === directory.current?.provider, + )) { rows.push({ id: `failure/${failure.id}`, label: failure.id === "deepseek-account" ? t("provider.account") : failure.name, @@ -199,7 +201,7 @@ export function apply(ctx: ClientContext): void { }; }, }, - ModelSelect, + ModelControls, ), ); }); diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/locales.ts b/apps/web-ui/packages/client/ui-model-selection/src/client/locales.ts index eb696dbfb..af8addb46 100644 --- a/apps/web-ui/packages/client/ui-model-selection/src/client/locales.ts +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/locales.ts @@ -11,6 +11,11 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { "provider.account": "DeepSeek 账号", + "harness.select": "选择 Harness", + "harness.aria": "选择 Harness,当前 {name}", + "harness.bound": "当前会话已绑定此 Harness;使用其他 Harness 请新建会话。", + "harness.unavailable": "不可用", + "harness.empty": "没有就绪的 Harness。", "command.label": "模型", "command.description": "选择本会话使用的模型", "option.loadError": "目录加载失败:{message}", @@ -42,6 +47,12 @@ export type ModelKey = keyof typeof zh; /** English dictionary, checked complete against the zh key set. */ export const en = { "provider.account": "DeepSeek Account", + "harness.select": "Select Harness", + "harness.aria": "Select Harness, current {name}", + "harness.bound": + "This conversation is bound to its Harness. Start a new conversation to use another.", + "harness.unavailable": "Unavailable", + "harness.empty": "No ready Harnesses.", "command.label": "Model", "command.description": "Select the model for this conversation", "option.loadError": "Catalog failed to load: {message}", diff --git a/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css b/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css index b40748e22..dfd2096a9 100644 --- a/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css +++ b/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css @@ -100,3 +100,23 @@ .chevronOpen { transform: rotate(180deg); } + +.optionCopy { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + max-width: 270px; + white-space: normal; + text-align: left; +} + +.description { + font-size: 11px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + +.dangerous { + color: var(--dsw-alias-state-danger-primary, #c2413b); +} diff --git a/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx index 7d1d43da2..5fd6d0bcf 100644 --- a/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx +++ b/apps/web-ui/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx @@ -1,235 +1,148 @@ +/** Session-scoped native permissions: retain Adapter labels, descriptions, risk and creation scope. */ import { useEffect, useState } from "react"; -import type { ReactNode } from "react"; import clsx from "clsx"; import { IconChevronDownOutlineRegular, + IconWarningOutlineRegular, Menu, - PermissionIconFullAccessRegular, - PermissionIconReadOnlyRegular, - PermissionIconWorkspaceWriteRegular, RiskConfirmation, + Toast, } from "@deepseek-ai/dsh-client-ui-primitives"; -import type { MenuEntry } from "@deepseek-ai/dsh-client-ui-primitives"; -import type { - HostObservable, - InjectFace, - PropsLocale, - PropsRuntime, -} from "@deepseek-ai/dsh-client-ui-slots"; -import type { PresetOption } from "@deepseek-ai/dsh-permission-presets/client"; -// Type-only: pulls the conversation-owned permission slot declaration and -// the standard session projection hook into this package's Client face. +import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; -import type { PermissionCatalogState } from "./catalog.ts"; +import type { NativePermissionMode } from "./native.ts"; import { PERMISSION_ACCESS_NS } from "./locales.ts"; -import { - AUTO_REVIEW_PRESET as AUTO_REVIEW, - displayPermissionPreset, - FULL_ACCESS_PRESET as FULL_ACCESS, -} from "./presentation.ts"; import css from "./PermissionSelect.module.css"; -/* Permission glyphs follow currentColor so the trigger and menu rows tint the - shared product artwork with their own text color. */ - -const permissionGlyphs = new Map([ - ["read-only", ], - ["workspace-write", ], - [FULL_ACCESS, ], -]); - -/** Glyph for a permission option value; host-configured names outside the design set get none. */ -function permissionGlyph(value: string): ReactNode | undefined { - return permissionGlyphs.get(value); -} - -function permissionLabel(value: string, name: string, t: PermissionSelectProps["t"]): string { - if (value === AUTO_REVIEW) return t("auto.label"); - return displayPermissionPreset(value, name, (key) => t(key)); -} - -function optionBadge(value: string, t: PermissionSelectProps["t"]): string | undefined { - return value === AUTO_REVIEW ? t("auto.badge") : undefined; -} - -/** Resolve locale-owned copy for the shipped Auto option; preserve host copy for other presets. */ -function optionDescription( - option: PresetOption, - t: PermissionSelectProps["t"], -): string | undefined { - return option.value === AUTO_REVIEW ? t("auto.description") : option.description; -} - -/** Business face injected by the permission package's slot registration. */ export interface PermissionSelectInjected { - hooks: { - /** One process catalog shared with the slash popup. */ - permissionCatalog: HostObservable; - }; - /** Submit one current-session preset through the existing command writer. */ - select: (preset: string) => Promise; + /** Send the native mode ID through the existing command path and surface rejection. */ + select: (modeId: string) => Promise; } -/** Complete props derived from the conversation slot, injected hooks, and locale. */ export type PermissionSelectProps = PropsRuntime<"conversation.input.permission"> & InjectFace & PropsLocale; -export function PermissionSelect({ - locked, - select, - usePermissionCatalog, - useProjection, - t, -}: PermissionSelectProps) { +export function PermissionSelect({ locked, select, useProjection, t }: PermissionSelectProps) { const selection = useProjection("permissions"); - const catalog = usePermissionCatalog((state) => state.value); + const native = useProjection("nativePermissions"); const [pick, setPick] = useState(null); const [open, setOpen] = useState(false); - const [confirmation, setConfirmation] = useState(null); + const [confirmation, setConfirmation] = useState(null); const [acknowledged, setAcknowledged] = useState(false); + const [error, setError] = useState<{ text: string; seq: number } | null>(null); + const unavailable = locked || native?.locked === true; useEffect(() => { if ( - !locked && - selection !== undefined && - catalog !== null && - (confirmation === null || catalog.options.some((option) => option.value === confirmation)) + !unavailable && + native?.selectable && + native.catalog && + (confirmation === null || native.catalog.modes.some((mode) => mode.id === confirmation.id)) ) return; setOpen(false); - setAcknowledged(false); setConfirmation(null); - }, [catalog, confirmation, locked, selection]); - - if (selection === undefined || catalog === null) return null; + setAcknowledged(false); + }, [native, unavailable, confirmation]); - const currentValue = - pick !== null && catalog.options.some((option) => option.value === pick) - ? pick - : selection.currentValue; - const current = catalog.options.find((option) => option.value === currentValue); - const currentLabel = - current === undefined - ? permissionLabel(currentValue, currentValue, t) - : permissionLabel(current.value, current.name, t); + if (selection === undefined || !native?.selectable || native.catalog === null) return null; + const current = native.catalog.modes.find((mode) => mode.id === selection.currentValue); + const label = current?.label ?? selection.currentValue; const busy = pick !== null || confirmation !== null; - - const items: MenuEntry[] = catalog.options.map((option) => { - const icon = permissionGlyph(option.value); - const label = permissionLabel(option.value, option.name, t); - const badge = optionBadge(option.value, t); - return { - id: option.value, - label: - badge === undefined ? ( - label - ) : ( - - {label} - {badge} - - ), - ...(icon === undefined ? {} : { icon }), - }; - }); - - const submit = (id: string): void => { - setPick(id); - void select(id) - .catch(() => false) - .then(() => { - setPick(null); - }); - }; - - const choose = (id: string): void => { - setOpen(false); - if (id === selection.currentValue) return; - if (id === FULL_ACCESS || id === AUTO_REVIEW) { - setAcknowledged(false); - setConfirmation(id); - return; - } - submit(id); - }; - - const closeConfirmation = (): void => { + const closeConfirmation = () => { setAcknowledged(false); setConfirmation(null); }; - - const confirmSelection = (id: string): void => { - closeConfirmation(); - submit(id); + const submit = (id: string): void => { + setPick(id); + void select(id) + .catch((cause: unknown) => { + const text = cause instanceof Error ? cause.message : String(cause); + setError((previous) => ({ text, seq: (previous?.seq ?? 0) + 1 })); + }) + .finally(() => setPick(null)); }; - const confirmationTitle = - confirmation === AUTO_REVIEW ? t("auto.confirm.title") : t("confirm.title"); - const confirmationDescription = - confirmation === AUTO_REVIEW ? t("auto.confirm.description") : t("confirm.description"); - const confirmationAcknowledge = - confirmation === AUTO_REVIEW ? t("auto.confirm.acknowledge") : t("confirm.acknowledge"); - const confirmationEnable = - confirmation === AUTO_REVIEW ? t("auto.confirm.enable") : t("confirm.enable"); - const currentBadge = optionBadge(currentValue, t); - const currentAccessibleLabel = - currentBadge === undefined ? currentLabel : `${currentLabel} ${currentBadge}`; - return ( <> { + portal + side="top" + selectedId={selection.currentValue} + items={native.catalog.modes.map((mode) => ({ + id: mode.id, + label: ( + + {mode.label} + {mode.description ? ( + {mode.description} + ) : null} + + ), + ...(mode.dangerous ? { icon: } : {}), + }))} + onSelect={(id) => { setOpen(false); + if (unavailable || busy || id === selection.currentValue) return; + const mode = native.catalog?.modes.find((item) => item.id === id); + if (!mode) return; + if (mode.dangerous) { + setAcknowledged(false); + setConfirmation(mode); + } else submit(id); }} - side="top" - portal + onClose={() => setOpen(false)} + footer={ + native.scope === "atCreate" + ? [{ type: "label", id: "scope", text: t("native.atCreate") }] + : [] + } anchor={ } /> - {confirmation !== null && ( + {confirmation ? ( { - confirmSelection(confirmation); + const id = confirmation.id; + closeConfirmation(); + submit(id); }} /> - )} + ) : null} + {error ? ( + } + onDone={() => setError(null)} + /> + ) : null} ); } diff --git a/apps/web-ui/packages/client/ui-permission-presets/src/client/index.ts b/apps/web-ui/packages/client/ui-permission-presets/src/client/index.ts index 6921965cb..895928d85 100644 --- a/apps/web-ui/packages/client/ui-permission-presets/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-permission-presets/src/client/index.ts @@ -1,243 +1,113 @@ -/** - * Permission preset plugin, browser half — a popupSelect DECORATION hung on - * the host `/permission` command: one flat list of presets, current value - * marked active, a pick executes the switch. One process catalog directory is - * shared with the composer slot; Session projection carries current value - * only. The decoration owns only the - * bare invocation; the host command keeps its catalog row, the argued path - * (`/permission ` still switches directly), and the lifecycle - * logging. Options read the process catalog and the active mark reads the - * session's `permissions` projection; a - * pick submits the `/permission ` command line, so both surfaces - * write through one path and the pushed projection frame is the one - * confirmation. The Full access row carries the same explicit risk gate as - * the composer chip; the shared popup shell owns the modal mechanics. - * The General-settings row separately writes the default preset for sessions - * created later through the host Settings API. - */ +/** Native permission controls share one session projection and the existing Adapter command path. */ import type { Context as ClientContext } from "@deepseek-ai/cordis"; import type { SessionFace } from "@deepseek-ai/dsh-api-session-controller/client"; import type { SessionId } from "@deepseek-ai/dsh-api-remotes/client"; -import type { - PermissionCatalog, - PermissionSelection, -} from "@deepseek-ai/dsh-permission-presets/client"; -// Direct dependency: catalog settlements are fenced by the actual connection -// generation rather than by a parallel domain counter. -import type {} from "@deepseek-ai/dsh-client-connection/client"; -// Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from "@deepseek-ai/dsh-client-locale/client"; -// Type-only: the settings slot types (this package registers a General row). -import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"; import type {} from "@deepseek-ai/dsh-client-ui-session/client"; import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; -// Type-only: pulls the ctx.remote merge and the forwarded-event key face -// (the settings invalidation rides the allowlist) into this program. import type {} from "@deepseek-ai/dsh-api-remotes/client"; import type { CommandUiContract, SelectOption } from "@deepseek-ai/dsh-client-ui-commands/client"; -import type { ClientSessionContext } from "@deepseek-ai/dsh-client-ui-input-trigger/client"; -import type { TranslateNS } from "@deepseek-ai/dsh-client-ui-slots"; -import { PermissionCatalogDirectory } from "./catalog.ts"; -import { PermissionSelect } from "./PermissionSelect.tsx"; -import type { PermissionSelectInjected } from "./PermissionSelect.tsx"; -import { PermissionRow } from "./PermissionRow.tsx"; -import type { PermissionRowInjected } from "./PermissionRow.tsx"; -import { accessEn, accessZh, en, PERMISSION_ACCESS_NS, zh } from "./locales.ts"; -import { AUTO_REVIEW_PRESET, displayPermissionPreset, FULL_ACCESS_PRESET } from "./presentation.ts"; -import { PermissionPresetSettingsController } from "./settings-store.ts"; +import { PermissionSelect, type PermissionSelectInjected } from "./PermissionSelect.tsx"; +import type { NativePermissions } from "./native.ts"; +import { accessEn, accessZh, PERMISSION_ACCESS_NS } from "./locales.ts"; export type { PermissionRowInjected, PermissionRowProps } from "./PermissionRow.tsx"; export type { PermissionCatalogState } from "./catalog.ts"; export type { PermissionSelectInjected, PermissionSelectProps } from "./PermissionSelect.tsx"; export type { PermissionDefaultOption, PermissionSettingsState } from "./settings-store.ts"; -/** Required services (cordis fiber inject). */ -export const inject = [ - "commandUi", - "connection", - "sessions", - "slots", - "locale", - "remote", - "remote.permissionPresets", - "remote.settings", - "configForms", - "settingsSchema", -]; +export const inject = ["commandUi", "sessions", "slots", "locale", "remote", "remote.commands"]; declare module "@deepseek-ai/dsh-client-ui-slots" { interface LocaleNamespaceMap { - /** Current-session permission picker and confirmation copy. */ "permission.access": keyof typeof accessEn; } } -/** Read one session's current permissions projection value (undefined = capability absent). */ -function selectionOf(session: SessionFace | undefined): PermissionSelection | undefined { - return session?.projections.faceOf("permissions").getSnapshot() as - PermissionSelection | undefined; -} - -/** Join the process catalog with one Session's current value. */ -function optionsOf( - catalog: PermissionCatalog, - currentValue: string, - t: TranslateNS, -): SelectOption[] { - return catalog.options.map((option) => ({ - id: option.value, - label: - option.value === AUTO_REVIEW_PRESET - ? t("auto.label") - : displayPermissionPreset(option.value, option.name, t), - ...(option.value === AUTO_REVIEW_PRESET ? { badge: t("auto.badge") } : {}), - ...(option.value === AUTO_REVIEW_PRESET - ? { detail: t("auto.description") } - : option.description !== undefined - ? { detail: option.description } - : {}), - ...(option.value === currentValue ? { active: true } : {}), - ...(option.value === FULL_ACCESS_PRESET || option.value === AUTO_REVIEW_PRESET - ? { - confirmation: { - title: t(option.value === AUTO_REVIEW_PRESET ? "auto.confirm.title" : "confirm.title"), - description: t( - option.value === AUTO_REVIEW_PRESET - ? "auto.confirm.description" - : "confirm.description", - ), - acknowledgeLabel: t( - option.value === AUTO_REVIEW_PRESET - ? "auto.confirm.acknowledge" - : "confirm.acknowledge", - ), - cancelLabel: t("confirm.cancel"), - confirmLabel: t( - option.value === AUTO_REVIEW_PRESET ? "auto.confirm.enable" : "confirm.enable", - ), - }, - } - : {}), - })); +function nativeOf(session: SessionFace | undefined): NativePermissions | null | undefined { + return session?.projections.faceOf("nativePermissions").getSnapshot() as + NativePermissions | null | undefined; } -/** - * Client plugin body: register the /permission popup picker over the - * permissions projection. - * @param ctx - client root context. +/** Mount native choices only where the current Harness advertises them. + * @param ctx Browser plugin context. */ export function apply(ctx: ClientContext): void { const command = ctx.get("commandUi") as CommandUiContract; const sessions = ctx.sessions; ctx.effect( () => ctx.locale.register(PERMISSION_ACCESS_NS, { zh: accessZh, en: accessEn }), - "ui-permission: current-session dictionaries", + "ui-permission: native dictionaries", ); const t = ctx.locale.bind(PERMISSION_ACCESS_NS); - const sessionFor = (session: ClientSessionContext): SessionFace | undefined => - sessions.binding(session.sessionId)?.session; - const submit = async (sessionId: SessionId, preset: string): Promise => { - const live = sessions.binding(sessionId)?.session; - if (live === undefined) throw new Error("this session is not materialized yet"); - const result = await live.command(`/permission ${preset}`); - if (!result.ok) { - throw new Error(`permission switch failed: ${result.error.code}: ${result.error.message}`); - } - if (!result.value.matched) throw new Error("the host offers no /permission command"); + const submit = async (sessionId: SessionId, modeId: string): Promise => { + if (sessions.binding(sessionId) === undefined) + throw new Error("This session is not materialized yet."); + const native = nativeOf(sessions.binding(sessionId)?.session); + if ( + !native?.selectable || + native.locked || + !native.catalog?.modes.some((mode) => mode.id === modeId) + ) + throw new Error("Native permission mode selection is unavailable for this session."); + const result = await ctx.remote.commands.execute(sessionId, `/permission ${modeId}`, []); + if (!result.ok) throw new Error(result.error.message); + if (result.value === undefined) throw new Error("The Host offers no /permission command."); + if (result.value.result?.kind === "error") + throw new Error(result.value.result.text ?? "Native permission switch failed."); return true; }; - - const catalog = new PermissionCatalogDirectory(ctx); - ctx.effect( - () => () => { - catalog.dispose(); - }, - "ui-permission: process catalog directory", - ); - ctx.effect( - // Only an invalidation makes displayed options stale; publishing the result - // of a read a displayed picker waits for must leave it open with its failure - // and retry state intact. - () => - catalog.invalidations.subscribe(() => { - command.dismiss("permission"); - }), - "ui-permission: dismiss stale slash choices", - ); - - ctx.effect( - () => ctx.locale.register("settings.permission", { zh, en }), - "ui-permission: settings row dictionaries", - ); - - // The shared ConfigForm mirror updates after document commits and reconnects. - const controller = new PermissionPresetSettingsController( - ctx.configForms.describe(), - ctx, - catalog, - ); - const load = (): Promise => controller.load(); - const select = (preset: string): Promise => controller.select(preset); - const injected = (): PermissionRowInjected => ({ - hooks: { permission: controller.store }, - load, - select, - }); - - ctx.effect( - () => () => { - controller.dispose(); - }, - "ui-permission: settings row directory", - ); - - ctx.slots.inject("settings.general.item", () => - ctx.slots.register( - { - name: "settings.general.item", - id: "permission", - order: -20, - locale: "settings.permission", - inject: injected, - }, - PermissionRow, - ), - ); - ctx.slots.inject("conversation.input.permission", () => ctx.slots.register( { name: "conversation.input.permission", locale: PERMISSION_ACCESS_NS, inject: (sessionId: SessionId): PermissionSelectInjected => ({ - hooks: { permissionCatalog: catalog.store }, - select: (preset) => submit(sessionId, preset), + select: (modeId) => submit(sessionId, modeId), }), }, PermissionSelect, ), ); - ctx.effect( () => command.decorate({ name: "permission", - // The Session's current value alone decides availability. A missing catalog - // surfaces through `options()`, which keeps the picker's own retry entry - // reachable after a failed read instead of hiding the command. - available: (session) => selectionOf(sessionFor(session)) !== undefined, + available: (session) => { + const native = nativeOf(sessions.binding(session.sessionId)?.session); + return native?.selectable === true && !native.locked; + }, ui: { kind: "popupSelect", options: async (session) => { - const selection = selectionOf(sessionFor(session)); - if (selection === undefined) - throw new Error("permission presets are not available on this host"); - return optionsOf(await catalog.load(), selection.currentValue, t); + const live = sessions.binding(session.sessionId)?.session; + const native = nativeOf(live); + if (!native?.selectable || native.locked || !native.catalog) + throw new Error("Native permissions unavailable."); + const current = live?.projections.faceOf("permissions").getSnapshot() as + { currentValue: string } | undefined; + return native.catalog.modes.map((mode): SelectOption => ({ + id: mode.id, + label: mode.label, + ...(mode.description ? { detail: mode.description } : {}), + ...(mode.id === current?.currentValue ? { active: true } : {}), + ...(mode.dangerous + ? { + confirmation: { + title: t("native.confirm.title", { name: mode.label }), + description: mode.description ?? t("native.confirm.description"), + acknowledgeLabel: t("confirm.acknowledge"), + cancelLabel: t("confirm.cancel"), + confirmLabel: t("native.confirm.enable"), + }, + } + : {}), + })); }, onSelect: (option, session) => submit(session.sessionId, option.id).then(() => undefined), }, }), - "ui-permission: /permission decoration", + "ui-permission: native /permission picker", ); } diff --git a/apps/web-ui/packages/client/ui-permission-presets/src/client/locales.ts b/apps/web-ui/packages/client/ui-permission-presets/src/client/locales.ts index 39282bc7c..c1061bae1 100644 --- a/apps/web-ui/packages/client/ui-permission-presets/src/client/locales.ts +++ b/apps/web-ui/packages/client/ui-permission-presets/src/client/locales.ts @@ -43,6 +43,11 @@ export const en = { /** Simplified Chinese dictionary for the current-session popup gate. */ export const accessZh = { mode: "访问模式,当前:{name}", + "native.atCreate": "此 Harness 仅允许在创建原生会话前选择权限模式。", + "native.confirm.title": "启用 {name}?", + "native.confirm.description": "此原生模式可能跳过权限确认,请确认你信任当前任务。", + "native.confirm.enable": "启用此模式", + "native.error": "权限切换失败:{message}", close: "关闭", "preset.readOnly": "仅可查看", "preset.workspaceWrite": "工作区内修改", @@ -69,6 +74,13 @@ export type PermissionAccessKey = keyof typeof accessZh; /** English dictionary for the current-session popup gate. */ export const accessEn = { mode: "Access mode, current: {name}", + "native.atCreate": + "This Harness only allows permission selection before creating its native session.", + "native.confirm.title": "Enable {name}?", + "native.confirm.description": + "This native mode may skip permission checks. Only enable it for a trusted task.", + "native.confirm.enable": "Enable mode", + "native.error": "Permission switch failed: {message}", close: "Close", "preset.readOnly": "Read Only", "preset.workspaceWrite": "Workspace Write", diff --git a/apps/web-ui/packages/client/ui-permission-presets/src/client/native.ts b/apps/web-ui/packages/client/ui-permission-presets/src/client/native.ts new file mode 100644 index 000000000..50ebeeb48 --- /dev/null +++ b/apps/web-ui/packages/client/ui-permission-presets/src/client/native.ts @@ -0,0 +1,22 @@ +/** Native metadata projected by the Web Host for this exact session. */ +export interface NativePermissionMode { + id: string; + label: string; + description?: string; + dangerous?: boolean; +} + +export interface NativePermissions { + harnessId: string; + catalog: { modes: NativePermissionMode[]; defaultModeId: string } | null; + selectable: boolean; + scope: "live" | "atCreate"; + locked: boolean; +} + +declare module "@deepseek-ai/dsh-session-projection/types" { + interface SessionProjectionMap { + /** No catalog means the Harness does not advertise selectable native permission modes. */ + nativePermissions: NativePermissions | null; + } +} diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/contract/slots.ts b/apps/web-ui/packages/client/ui-workspace/src/client/contract/slots.ts index 04851611f..ece2355a6 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/apps/web-ui/packages/client/ui-workspace/src/client/contract/slots.ts @@ -147,6 +147,12 @@ declare module "@deepseek-ai/dsh-client-ui-slots" { * An archived row keeps that cell blank — neither its status dot nor this * seat renders there, and its live status appears on the hover card only. */ + /** Persistent identity decoration, independent of transient activity and available on archived rows. */ + "sidebar.session.row.identity": { + kind: "list"; + scope: "root"; + owner: SessionRowScheduleOwnerProps; + }; "sidebar.session.row.leading": { kind: "list"; scope: "root"; @@ -490,6 +496,7 @@ export type WorkspaceBrowserProps = PropsRuntime<"sidebar.workspaces"> & | "sidebar.workspaces.directoryFlow" | "sidebar.workspaces.session.menu.item" | "sidebar.workspaces.session.row.action" + | "sidebar.session.row.identity" | "sidebar.session.row.leading" | "sidebar.session.row.hover" > & diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/index.ts b/apps/web-ui/packages/client/ui-workspace/src/client/index.ts index 7a39849d6..a2f550d4d 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-workspace/src/client/index.ts @@ -354,6 +354,7 @@ export function apply(ctx: Context): void { }, }, "sidebar.workspaces.session.row.action": { kind: "list", scope: "root" }, + "sidebar.session.row.identity": { kind: "list", scope: "root" }, "sidebar.session.row.leading": { kind: "list", scope: "root" }, "sidebar.session.row.hover": { kind: "list", scope: "root" }, }, diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css index 52c36485e..7be85b649 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -117,6 +117,7 @@ } .slot { + position: relative; flex: none; width: 16px; height: 20px; @@ -126,6 +127,20 @@ color: var(--dsw-alias-label-tertiary); } +.statusOverlay { + display: contents; +} + +/* Keep the actual Harness visible while activity overlays a small status badge. */ +.slot:has([data-harness-id]) .statusOverlay { + display: inline-flex; + position: absolute; + right: -3px; + bottom: -3px; + transform: scale(0.65); + transform-origin: bottom right; +} + .visuallyHidden { position: absolute; width: 1px; diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx index 080a28377..e450dd1b7 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -51,6 +51,7 @@ type RowTranslate = WorkspaceBrowserProps["t"]; type RowRenderSlots = PropsRenderSlots< | "sidebar.workspaces.session.menu.item" | "sidebar.workspaces.session.row.action" + | "sidebar.session.row.identity" | "sidebar.session.row.leading" | "sidebar.session.row.hover" >["renderSlot"]; @@ -653,6 +654,7 @@ export function SessionNodeItem({ } & PropsRenderSlots< | "sidebar.workspaces.session.menu.item" | "sidebar.workspaces.session.row.action" + | "sidebar.session.row.identity" | "sidebar.session.row.leading" | "sidebar.session.row.hover" >) { @@ -735,13 +737,16 @@ export function SessionNodeItem({ blank — the grayed row carries the archived look — and their live status stays on the hover card only. */} - {!row.archived && - !row.blank && - (showStatus ? ( - - ) : ( - renderSlot("sidebar.session.row.leading", { sessionId: node.id }) - ))} + {renderSlot("sidebar.session.row.identity", { sessionId: node.id })} + + {!row.archived && + !row.blank && + (showStatus ? ( + + ) : ( + renderSlot("sidebar.session.row.leading", { sessionId: node.id }) + ))} + & { diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index 2ec33060e..7cf53e32f 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -22,11 +22,25 @@ npm run start:web -- --port 3181 发行目录为 `packages/web-server/dist/codexhost-web`,包含 Node 服务、Web 界面和自包含 Adapter。整体目录可以移动到仓库外运行,无需 CH.app、Codex Desktop、源码目录或仓库的 node_modules;Harness CLI 和其认证仍由用户安装、配置。 +## Harness 与 Model 选择 + +Composer 的模型/推理等级控件旁提供独立的 Harness 图标按钮。菜单沿用 Desktop 的“图标、Harness 名称、当前勾选”方式;图标来自插件资源,不从 Model Provider 名称推测。就绪插件可选,检查失败的条目标记为不可用。 + +新会话可切换 Harness;选择时采用该 Harness 目录的首个可用 Model 及其默认推理等级,随后可单独选择其他 Model。模型菜单和 `/model` 只列当前 Harness 的模型。两个控件共用原来的目录、`session/selectModel` 和确认后的持久投影,不维护另一份选中状态。 + +会话有原生历史后固定 Harness;菜单仍展示其他 Harness,但禁用切换并提示新建会话。当前 Harness 内的 Model 选择保持可用。重新连接或刷新按持久投影恢复,不把现有 Native Session 迁到另一个 Harness。 + +## 原生权限与会话标识 + +权限菜单及 `/permission` 读取当前会话的 Adapter 权限目录,保留原生模式 ID、标签、描述与危险标记;不再把 `auto` 解释成 DSH 的 Auto review,也不使用进程全局权限目录为不同 Harness 套用相同预设。危险模式有显式确认;无原生可选权限能力的 Harness 隐藏入口。`atCreate` 模式仅在原生会话创建前可选,之后锁定;原生拒绝显示错误,不伪造切换成功。未打开原生会话时保存所选 ID并在 `Adapter.open` 时传入,已有会话通过 `permissionMode.select` 下发。跨 Harness 的草稿切换恢复目标 Harness 原生默认值,不继承同名模式的语义。全局默认权限设置入口不再显示,因为各 Harness 的模式与生效范围并不相同。 + +左侧会话行读取服务端持久元数据中的绑定 Harness(草稿读取该会话的已选 Harness),显示插件品牌图标,不从当前 Composer 或其他会话的默认选择推测。未打开的历史会话同样显示,刷新可恢复;运行、审批和未读状态叠加在图标旁,归档保留身份。缺少图标资源时使用通用图标,不伪造其他品牌。 + ## 并发运行与预览 -`start:web` 使用隔离的 Claude Code 预览入口,固定监听 localhost 并保留访问令牌认证。服务子进程不继承 `CODEXHOST_*`、`NODE_OPTIONS` 或 `NODE_PATH`。预览默认数据目录为 `~/.codexhost-web-preview`,工作区为 `~/codexhost-web-preview-workspace`;支持 `--port`、`--data`、`--workspace`。 +`start:web` 使用隔离的 Claude Code 预览入口,固定监听 localhost 并保留访问令牌认证。服务子进程不继承 `CODEXHOST_*`、`NODE_OPTIONS` 或 `NODE_PATH`。预览默认数据目录为 `~/.codexhost-web-preview`,工作区为 `~/codexhost-web-preview-workspace`;支持 `--port`、`--data`、`--workspace`,以及逗号分隔的 `--harness `。默认仅 Claude Code;`npm run start:web -- --harness all` 启用本发行包除 Codex、Pi 和用量统计专用插件外的所有会话插件。显式选择 Codex/Pi、缺失插件或用量统计插件会在启动前报错。 -发行包包含当前源码构建的会话插件,但预览只启用 Claude Code;Codex、Pi 等 CLI 的独立启动入口、插件所需的可选 Host 上下文以及完整界面能力仍需独立验收。不要从 CH 承载的 Agent 会话直接启动继承原环境的 `server.mjs`,也不要把 Desktop 的环境变量恢复到预览进程。 +启用插件不等于机器已安装、认证对应 CLI,也不等于所有能力已完成 Web 验收。模型菜单只列检查就绪的 Harness;未安装的不会列为可用,检查失败由目录接口报告。Codex、Pi 的独立启动入口仍未验证,隔离预览始终拒绝启用;其他插件所需的可选 Host 上下文以及完整界面能力也需逐项验收。不要从 CH 承载的 Agent 会话直接启动继承原环境的 `server.mjs`,也不要把 Desktop 的环境变量恢复到预览进程。 Web 和 Desktop 可以共享 CLI 安装及登录账号,不应同时恢复并写入同一个 Native Session,也不应让两个 Agent 同时修改同一工作目录。Web Host 元数据独立,不意味着原生 Harness 会话文件或账号额度隔离。 diff --git a/packages/web-server/README.md b/packages/web-server/README.md index edc7afeb8..a8a28b126 100644 --- a/packages/web-server/README.md +++ b/packages/web-server/README.md @@ -4,7 +4,7 @@ An independent Node Web Host for the responsive DSH-derived UI. It loads the rep Build from the repository root with `npm run build:web`. The default plugin input is the same checkout's `packages/host-runtime/dist/plugins`, built by the existing CH release plugin builder; the installed CodexHost.app is not consulted. Usage-only plugins are excluded from conversations. The standalone Codex Adapter lives in `packages/adapters/codex`; no Harness implementation is duplicated here. -`npm run start:web` uses the isolated, authenticated Claude Code preview. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. +`npm run start:web` uses the isolated, authenticated preview, with Claude Code enabled by default. Add `-- --harness all` to enable every bundled session plugin except Codex/Pi, or `-- --harness ` to select specific plugins. Codex/Pi, missing plugins and usage-only plugins are rejected before starting; selection does not install/authenticate CLIs or imply full capability verification. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. Node test-runner suites are owned by this package's `test`, `test:distribution` and `test:browser` scripts; the root TypeScript test command invokes the unit suites separately from Vitest. The browser and distribution suites require built frontend artifacts. They use temporary data and a fake Harness, never real model submissions. diff --git a/packages/web-server/scripts/pack.ts b/packages/web-server/scripts/pack.ts index 53a5a4761..d470a9b29 100644 --- a/packages/web-server/scripts/pack.ts +++ b/packages/web-server/scripts/pack.ts @@ -3,7 +3,7 @@ * * dist/codexhost-web/ * server.mjs bundled server (ws, web-push, qrcode-terminal inlined) - * preview.mjs isolated localhost Claude Code launcher + * preview.mjs isolated localhost launcher (excludes Codex/Pi) * web/ Vite shell + composed client plugins + plugins.json * adapters/codex/ bundled Codex Harness adapter * adapters// optional prebuilt Harness plugins @@ -160,9 +160,11 @@ writeFileSync( To preview alongside CodexHost Desktop, use \`node preview.mjs\` (requires bundled Claude Code). This launcher removes inherited CODEXHOST_* variables, NODE_OPTIONS and NODE_PATH from the server environment. -It enables only Claude Code from this distribution, listens on 127.0.0.1, keeps authentication enabled, +It defaults to Claude Code, listens on 127.0.0.1, keeps authentication enabled, and uses ~/.codexhost-web-preview and ~/codexhost-web-preview-workspace. -Optional preview flags: --port, --data, --workspace. Stop with Ctrl+C. +Use \`--harness all\` for every bundled session plugin except Codex/Pi, or \`--harness \` for a subset. +Selection requires installed/authenticated native CLIs and does not imply full Web capability verification. +Optional preview flags: --port, --data, --workspace, --harness. Stop with Ctrl+C. Do not launch server.mjs directly from a CodexHost-managed session with inherited Desktop settings. Codex and Pi are intentionally excluded from preview; their CLI routing still needs separate verification. diff --git a/packages/web-server/scripts/preview.ts b/packages/web-server/scripts/preview.ts index 0653720cb..b6b09b3a0 100644 --- a/packages/web-server/scripts/preview.ts +++ b/packages/web-server/scripts/preview.ts @@ -1,27 +1,61 @@ -/** Launch the packed Claude Code preview without inheriting CodexHost routing. */ +/** Launch the packed preview without inheriting CodexHost routing. */ import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; -import { previewLaunch } from "../src/preview-launch.ts"; +import { PREVIEW_EXCLUDED_HARNESSES, previewLaunch } from "../src/preview-launch.ts"; const { values } = parseArgs({ options: { port: { type: "string" }, data: { type: "string" }, workspace: { type: "string" }, + harness: { type: "string" }, }, }); const distribution = import.meta.filename.endsWith(".mjs") ? import.meta.dirname : resolve(import.meta.dirname, "../dist/codexhost-web"); -const manifest = JSON.parse( - readFileSync(resolve(distribution, "adapters/claude-code/manifest.json"), "utf8"), -) as { id: string }; -if (manifest.id !== "claude-code") - throw new Error("Preview requires a bundled Claude Code Adapter"); -const launch = previewLaunch(distribution, values, process.env); +const adapters = resolve(distribution, "adapters"); +const available = readdirSync(adapters, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && existsSync(resolve(adapters, entry.name, "manifest.json")), + ) + .map((entry) => ({ + directory: entry.name, + manifest: JSON.parse(readFileSync(resolve(adapters, entry.name, "manifest.json"), "utf8")) as { + id: string; + kind?: string; + }, + })); +const harness = + values.harness === "all" + ? available + .filter( + ({ manifest }) => + manifest.kind !== "usage" && !PREVIEW_EXCLUDED_HARNESSES.includes(manifest.id), + ) + .map(({ manifest }) => manifest.id) + .sort() + .join(",") + : values.harness; +const launch = previewLaunch( + distribution, + { ...values, ...(harness === undefined ? {} : { harness }) }, + process.env, +); +const selected = launch.args[launch.args.indexOf("--harness") + 1] as string; +for (const id of selected.split(",")) { + if ( + !available.some( + ({ directory, manifest }) => + directory === id && manifest.id === id && manifest.kind !== "usage", + ) + ) { + throw new Error(`Preview requires a bundled session Adapter for ${id}`); + } +} const child = spawn(process.execPath, launch.args, { env: launch.env, cwd: distribution, diff --git a/packages/web-server/src/harnesses.ts b/packages/web-server/src/harnesses.ts index 7281e174e..89c4b3fe2 100644 --- a/packages/web-server/src/harnesses.ts +++ b/packages/web-server/src/harnesses.ts @@ -74,7 +74,10 @@ export interface Adapter { export interface HarnessSessionLike { readonly harnessId: string; - readonly capabilities: unknown; + readonly capabilities: { + configuration?: { selectPermissionMode: boolean; permissionModeScope?: "live" | "atCreate" }; + steering?: { native?: boolean }; + }; readonly initialState: { nativeRef?: unknown; effectiveModel?: ModelRef; diff --git a/packages/web-server/src/main.ts b/packages/web-server/src/main.ts index aa7534cac..b984673ec 100644 --- a/packages/web-server/src/main.ts +++ b/packages/web-server/src/main.ts @@ -24,7 +24,7 @@ import { Sessions } from "./sessions.ts"; import { Settings } from "./settings.ts"; import { DataDir } from "./store.ts"; import { EventHub, RpcRegistry, StreamRegistry } from "./transport.ts"; -import { WebAssets, type AssetResponse } from "./web-assets.ts"; +import { WebAssets, contentTypeOf, type AssetResponse } from "./web-assets.ts"; import { Workspaces } from "./workspaces.ts"; /** Packed distribution: `server.mjs` sits next to `web/plugins.json`. */ @@ -205,7 +205,7 @@ const server = createServer((request, response) => { response, icon === undefined || !existsSync(icon) ? undefined - : { body: readFileSync(icon), contentType: "image/svg+xml" }, + : { body: readFileSync(icon), contentType: contentTypeOf(icon) }, ); return; } diff --git a/packages/web-server/src/preview-launch.ts b/packages/web-server/src/preview-launch.ts index 4b15ec606..f21e2d335 100644 --- a/packages/web-server/src/preview-launch.ts +++ b/packages/web-server/src/preview-launch.ts @@ -1,12 +1,16 @@ -/** Isolated, localhost-only Claude Code preview of the packed Web application. */ +/** Isolated, localhost-only preview of the packed Web application. */ import { homedir } from "node:os"; import { join, resolve } from "node:path"; -/** Optional preview paths and port; Harness selection is deliberately fixed. */ +/** Harnesses whose native CLI routing is not yet verified alongside Desktop. */ +export const PREVIEW_EXCLUDED_HARNESSES: readonly string[] = ["codex", "pi"]; + +/** Optional preview paths, port and comma-separated Harness ids. */ export interface PreviewOptions { port?: string; data?: string; workspace?: string; + harness?: string; } /** @@ -24,6 +28,14 @@ export function previewLaunch( args: string[]; env: NodeJS.ProcessEnv; } { + const harnesses = [ + ...new Set((options.harness ?? "claude-code").split(",").map((id) => id.trim())), + ]; + for (const id of harnesses) { + if (!/^[a-z0-9][a-z0-9-]*$/u.test(id) || PREVIEW_EXCLUDED_HARNESSES.includes(id)) { + throw new Error(`Harness ${id || "(empty)"} is not allowed in the isolated preview`); + } + } const env = Object.fromEntries( Object.entries(environment).filter(([key]) => { const name = key.toUpperCase(); @@ -41,7 +53,7 @@ export function previewLaunch( "--adapters", resolve(distribution, "adapters"), "--harness", - "claude-code", + harnesses.join(","), "--data", resolve(options.data ?? join(homedir(), ".codexhost-web-preview")), "--workspace", diff --git a/packages/web-server/src/session-presentation.ts b/packages/web-server/src/session-presentation.ts new file mode 100644 index 000000000..515d41520 --- /dev/null +++ b/packages/web-server/src/session-presentation.ts @@ -0,0 +1,40 @@ +/** Browser-facing views of the owning Harness's native configuration, not Host presets. */ +import type { Inspection, PermissionModeCatalog } from "./harnesses.ts"; + +export interface NativePermissionView { + harnessId: string; + catalog: PermissionModeCatalog | null; + selectable: boolean; + scope: "live" | "atCreate"; + locked: boolean; +} + +/** Derive selectable native modes without inventing a fallback for unsupported Harnesses. + * @param harnessId Owning Harness identity. + * @param inspection Native Adapter inspection. + * @param created Whether a native session already exists. + * @param configuration Optional live session capabilities. + * @returns Session-scoped native permission metadata. + */ +export function nativePermissionView( + harnessId: string, + inspection: Inspection, + created: boolean, + configuration?: { selectPermissionMode: boolean; permissionModeScope?: "live" | "atCreate" }, +): NativePermissionView { + const capability = + configuration ?? + (inspection.status === "ready" ? inspection.capabilities.configuration : undefined); + const scope = capability?.permissionModeScope ?? "live"; + const catalog = + inspection.status === "ready" && capability?.selectPermissionMode === true + ? (inspection.permissionModes ?? null) + : null; + return { + harnessId, + catalog, + scope, + selectable: catalog !== null, + locked: scope === "atCreate" && created, + }; +} diff --git a/packages/web-server/src/sessions.ts b/packages/web-server/src/sessions.ts index 1fa6527fc..02e99b16e 100644 --- a/packages/web-server/src/sessions.ts +++ b/packages/web-server/src/sessions.ts @@ -9,6 +9,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { HarnessRegistry, HarnessSessionLike } from "./harnesses.ts"; +import { nativePermissionView } from "./session-presentation.ts"; import { TurnProjector, type HostItem, @@ -174,21 +175,7 @@ export class Sessions { this.coldProjections.delete(sessionId); const runtime: Runtime = { meta, log, running: false, queue: [], interactions: new Map() }; this.runtimes.set(sessionId, runtime); - // A draft that has not chosen a Harness shows the current default Harness's permission mode. - if ( - meta.harnessId === undefined && - meta.permissionModeId === undefined && - meta.origin === undefined - ) { - void this.permissionCatalog(sessionId).then( - (catalog) => { - if (runtime.meta.harnessId === undefined && runtime.meta.permissionModeId === undefined) { - runtime.log.setProjection("permissions", { currentValue: catalog.defaultPreset }); - } - }, - () => undefined, - ); - } + void this.refreshNativePermissions(runtime).catch(() => undefined); return runtime; } @@ -223,7 +210,7 @@ export class Sessions { projections: { kind: "cached", asOfSeq: Math.max(runtime?.log.lastSeq ?? 0, 0), - values: projections, + values: { ...projections, harnessIdentity: this.harnessIdentity(meta) }, }, }; } @@ -337,6 +324,7 @@ export class Sessions { const session = result.value; runtime.harness = session; this.applyState(runtime, session.initialState); + void this.refreshNativePermissions(runtime).catch(() => undefined); void this.pump(runtime, session); return session; } @@ -418,6 +406,7 @@ export class Sessions { switch (event.type) { case "session.state.changed": this.applyState(runtime, event.state as HarnessSessionLike["initialState"]); + void this.refreshNativePermissions(runtime).catch(() => undefined); return; case "session.usage.changed": { const usage = event.usage as { @@ -905,20 +894,13 @@ export class Sessions { meta.selection = selection; this.saveMeta(meta); this.harnesses.preferredSelection = selection; - // An unbound session that switches Harness takes the new Harness's default permission mode - // unless the user picked one explicitly. - if ( - meta.harnessId === undefined && - meta.permissionModeId === undefined && - previousProvider !== selection.provider - ) { - void this.permissionCatalog(request.sessionId).then( - (catalog) => { - runtime.log.setProjection("permissions", { currentValue: catalog.defaultPreset }); - }, - () => undefined, - ); + if (meta.harnessId === undefined && previousProvider !== selection.provider) { + // Identical mode IDs from different Harnesses need not have identical semantics. + delete meta.permissionModeId; + this.saveMeta(meta); } + runtime.log.setProjection("harnessIdentity", this.harnessIdentity(meta)); + await this.refreshNativePermissions(runtime); this.data.writeJson("preferences.json", { ...this.data.readJson>("preferences.json", {}), defaultSelection: selection, @@ -1118,6 +1100,48 @@ export class Sessions { return (await this.harnesses.modelCatalog()).default.provider; } + private readonly permissionEpochs = new WeakMap(); + + private harnessIdentity(meta: SessionMeta): { id: string; name: string } | null { + const id = meta.harnessId ?? meta.selection?.provider; + return id === undefined ? null : { id, name: this.harnesses.manifest(id)?.name ?? id }; + } + + /** Publish native metadata on the same per-session projection stream as its effective mode. */ + private async refreshNativePermissions(runtime: Runtime): Promise { + const epoch = (this.permissionEpochs.get(runtime) ?? 0) + 1; + this.permissionEpochs.set(runtime, epoch); + runtime.log.setProjection("nativePermissions", null); + if (runtime.meta.harnessId === undefined && runtime.meta.selection === undefined) { + const catalog = await this.harnesses.modelCatalog(); + if (this.permissionEpochs.get(runtime) !== epoch) return; + if (runtime.meta.selection === undefined && catalog.groups.length > 0) { + runtime.meta.selection = catalog.default; + this.saveMeta(runtime.meta); + runtime.log.setProjection("modelSelection", { + lastUsed: runtime.meta.lastUsed ?? null, + next: catalog.default, + }); + } + } + const id = await this.harnessIdOf(runtime); + const inspection = await this.harnesses.inspect(id); + if (this.permissionEpochs.get(runtime) !== epoch || id !== (await this.harnessIdOf(runtime))) + return; + const view = nativePermissionView( + id, + inspection, + runtime.meta.nativeRef !== undefined || runtime.harness !== undefined, + runtime.harness?.capabilities.configuration, + ); + runtime.log.setProjection("harnessIdentity", this.harnessIdentity(runtime.meta)); + runtime.log.setProjection("nativePermissions", view); + if (view.catalog !== null) { + const mode = runtime.meta.permissionModeId ?? view.catalog.defaultModeId; + runtime.log.setProjection("permissions", { currentValue: mode }); + } + } + /** Permission presets offered for a session's Harness. */ async permissionCatalog(sessionId?: string): Promise<{ options: Array<{ value: string; name: string }>; @@ -1130,11 +1154,18 @@ export class Sessions { : this.runtime(sessionId); const harnessId = await this.harnessIdOf(runtime); const inspection = await this.harnesses.inspect(harnessId); - const modes = inspection.status === "ready" ? inspection.permissionModes : undefined; - const options = modes?.modes.map((mode) => ({ value: mode.id, name: mode.label })) ?? [ - { value: "default", name: "Default" }, - ]; - return { options, defaultOptions: options, defaultPreset: modes?.defaultModeId ?? "default" }; + const modes = + inspection.status === "ready" && inspection.capabilities.configuration.selectPermissionMode + ? inspection.permissionModes + : undefined; + const options = + modes?.modes.map((mode) => ({ + value: mode.id, + name: mode.label, + ...(mode.description === undefined ? {} : { description: mode.description }), + ...(mode.dangerous === undefined ? {} : { dangerous: mode.dangerous }), + })) ?? []; + return { options, defaultOptions: options, defaultPreset: modes?.defaultModeId ?? "" }; } // ---- subagents --------------------------------------------------------------------------- @@ -1594,6 +1625,7 @@ export class Sessions { const session = opened.value; runtime.harness = session; this.applyState(runtime, session.initialState); + void this.refreshNativePermissions(runtime).catch(() => undefined); void this.pump(runtime, session); this.updateStats(runtime); this.scheduleIdleRelease(runtime); @@ -1665,6 +1697,25 @@ export class Sessions { const runtime = this.runtime(sessionId); const commandId = randomUUID(); if (name === "permission") { + const id = await this.harnessIdOf(runtime); + const inspection = await this.harnesses.inspect(id); + const view = nativePermissionView( + id, + inspection, + runtime.meta.nativeRef !== undefined || runtime.harness !== undefined, + runtime.harness?.capabilities.configuration, + ); + if (!view.selectable || view.locked || runtime.opening !== undefined) { + return { + commandId, + result: { + kind: "error", + text: view.locked + ? "This Harness only allows permission selection before creating its native session." + : "Permission mode selection is not available for this session.", + }, + }; + } const catalog = await this.permissionCatalog(sessionId); const mode = catalog.options.find( (option) => @@ -1689,6 +1740,7 @@ export class Sessions { runtime.meta.permissionModeId = mode.value; this.saveMeta(runtime.meta); runtime.log.setProjection("permissions", { currentValue: mode.value }); + await this.refreshNativePermissions(runtime); runtime.log.append("command/run", { commandId, name, diff --git a/packages/web-server/src/web-assets.ts b/packages/web-server/src/web-assets.ts index b22f4b75e..fbb5ac34f 100644 --- a/packages/web-server/src/web-assets.ts +++ b/packages/web-server/src/web-assets.ts @@ -471,7 +471,11 @@ const CONTENT_TYPES: Record = { ".map": "application/json; charset=utf-8", }; -function contentTypeOf(path: string): string { +/** Resolve the MIME type shared by Web assets and Harness brand resources. + * @param path Asset file path. + * @returns Known content type, or an opaque binary fallback. + */ +export function contentTypeOf(path: string): string { const dot = path.lastIndexOf("."); return CONTENT_TYPES[dot < 0 ? "" : path.slice(dot)] ?? "application/octet-stream"; } diff --git a/packages/web-server/test/browser/native-presentation.test.ts b/packages/web-server/test/browser/native-presentation.test.ts new file mode 100644 index 000000000..cfac12683 --- /dev/null +++ b/packages/web-server/test/browser/native-presentation.test.ts @@ -0,0 +1,132 @@ +/** Native permission and sidebar identity acceptance, with scripted CLI-free Harnesses only. */ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { chromium, type Page } from "playwright"; +import { startServer } from "../support/server.ts"; + +const root = resolve(import.meta.dirname, "../.."); +const composer = "Describe what you want to build, / commands, @ files or sessions"; +async function shot(page: Page, name: string) { + if (!process.env.CODEXHOST_TEST_SCREENSHOTS) return; + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `${name}.png`), + animations: name.startsWith("native-rejection") ? "allow" : "disabled", + }); +} +async function sidebar(page: Page) { + const open = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (await open.isVisible()) await open.click(); +} +for (const width of [390, 1280]) + it( + `native permissions and durable row identities at ${width}px`, + { timeout: 60_000 }, + async (t) => { + const url = await startServer( + t, + root, + ["--import", "tsx", "src/main.ts"], + ["--adapters", "test/fake-harness"], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + isMobile: width < 600, + hasTouch: width < 600, + }); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(url); + const ask = () => + page.getByRole("button", { name: "Access mode, current: Ask", exact: true }); + await ask().click(); + await page.getByRole("menuitem").filter({ hasText: /^Yolo/ }).waitFor(); + assert.equal( + await page.getByText("Skip native permission checks", { exact: true }).count(), + 1, + ); + await shot(page, `native-modes-${width}`); + await page.getByRole("menuitem").filter({ hasText: /^Yolo/ }).click(); + const dialog = page.getByRole("dialog", { name: "Enable Yolo?", exact: true }); + await dialog.waitFor(); + assert.equal( + await dialog.getByRole("button", { name: "Enable mode", exact: true }).isDisabled(), + true, + ); + await shot(page, `native-risk-${width}`); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await ask().waitFor(); + await page.getByRole("textbox", { name: composer, exact: true }).fill("native fake"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("echo: native fake", { exact: true }).waitFor(); + await ask().click(); + await page.getByRole("menuitem", { name: "Rejected mode", exact: true }).click(); + await page + .getByText("Permission switch failed: Native permission refusal", { exact: true }) + .waitFor(); + await ask().waitFor(); + await shot(page, `native-rejection-${width}`); + await sidebar(page); + await page.getByRole("img", { name: "Fake Harness", exact: true }).waitFor(); + await page + .getByRole("button", { name: "New session", exact: true }) + .filter({ hasText: "New Session" }) + .click(); + await page + .getByRole("button", { name: "Select Harness, current Fake Harness", exact: true }) + .click(); + await page.getByRole("menuitem", { name: "Other Harness", exact: true }).click(); + const auto = () => + page.getByRole("button", { name: "Access mode, current: Native Auto", exact: true }); + await auto().click(); + await page.getByText("Native classifier, not a DSH review.", { exact: true }).waitFor(); + assert.equal(await page.getByText(/Auto review/).count(), 0); + assert.equal(await page.getByRole("menuitem").filter({ hasText: /^Ask$/ }).count(), 0); + await shot(page, `native-other-modes-${width}`); + await page.keyboard.press("Escape"); + await page.getByRole("textbox", { name: composer, exact: true }).fill("native other"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("echo: native other", { exact: true }).waitFor(); + assert.equal( + await auto().isDisabled(), + true, + "creation-only native modes lock after opening", + ); + await shot(page, `native-locked-${width}`); + await sidebar(page); + for (const name of ["Fake Harness", "Other Harness"]) { + const icon = page.getByRole("img", { name, exact: true }); + await icon.waitFor(); + assert.equal( + await icon.locator("img").getAttribute("src"), + `/harness-icons/${name === "Fake Harness" ? "fake" : "other"}`, + ); + assert.equal( + await icon.locator("img").evaluate((img) => (img as HTMLImageElement).naturalWidth > 0), + true, + ); + } + await shot(page, `native-sidebar-${width}`); + await page.getByRole("treeitem").filter({ hasText: "native fake" }).click(); + await ask().waitFor(); + await ask().click(); + await page.getByRole("menuitem").filter({ hasText: /^Yolo/ }).waitFor(); + assert.equal( + await page.getByText("Native classifier, not a DSH review.", { exact: true }).count(), + 0, + ); + await shot(page, `native-session-isolation-${width}`); + await page.keyboard.press("Escape"); + await page.reload(); + await ask().waitFor(); + await sidebar(page); + await page.getByRole("img", { name: "Fake Harness", exact: true }).waitFor(); + await page.getByRole("img", { name: "Other Harness", exact: true }).waitFor(); + await shot(page, `native-sidebar-restored-${width}`); + assert.deepEqual(errors, []); + }, + ); diff --git a/packages/web-server/test/browser/web.test.ts b/packages/web-server/test/browser/web.test.ts index 204f2535c..f4fd7377e 100644 --- a/packages/web-server/test/browser/web.test.ts +++ b/packages/web-server/test/browser/web.test.ts @@ -87,6 +87,90 @@ for (const viewport of [ ); } +for (const width of [390, 1280]) { + it( + `separates Harness identity from its models and locks native history at ${width}px`, + { timeout: 60_000 }, + async (t) => { + const url = await startServer( + t, + serverRoot, + ["--import", "tsx", "src/main.ts"], + ["--adapters", "test/fake-harness"], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + isMobile: width < 600, + hasTouch: width < 600, + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + const harness = () => + page.getByRole("button", { name: "Select Harness, current Other Harness", exact: true }); + await page + .getByRole("button", { name: "Select Harness, current Fake Harness", exact: true }) + .click(); + await page.getByRole("menuitem", { name: "Other Harness", exact: true }).waitFor(); + const bounds = await page.getByRole("menu").boundingBox(); + assert.ok( + bounds && bounds.x >= 0 && bounds.x + bounds.width <= width, + "Harness menu fits the viewport", + ); + await screenshot(page, `harness-menu-${width}`); + await page.getByRole("menuitem", { name: "Other Harness", exact: true }).click(); + await harness().waitFor(); + await page + .getByRole("button", { name: "Select model, current Other Model", exact: true }) + .click(); + await page + .getByRole("menuitem") + .filter({ hasText: /^Model/u }) + .click(); + await page.getByRole("menuitemradio", { name: "Other Alternate", exact: true }).waitFor(); + assert.equal( + await page.getByRole("menuitemradio", { name: "Fake Model", exact: true }).count(), + 0, + ); + await screenshot(page, `harness-models-${width}`); + await page.getByRole("menuitemradio", { name: "Other Alternate", exact: true }).click(); + await page + .getByRole("button", { name: "Select model, current Other Alternate", exact: true }) + .waitFor(); + await page + .getByRole("textbox", { name: composerLabel, exact: true }) + .fill("picker acceptance"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("echo: picker acceptance", { exact: true }).waitFor(); + await harness().click(); + await page + .getByText( + "This conversation is bound to its Harness. Start a new conversation to use another.", + { exact: true }, + ) + .waitFor(); + assert.equal( + await page.getByRole("menuitem", { name: "Fake Harness", exact: true }).isDisabled(), + true, + ); + assert.equal( + await page.getByRole("menuitem", { name: "Other Harness", exact: true }).isDisabled(), + false, + ); + await screenshot(page, `harness-bound-${width}`); + await page.keyboard.press("Escape"); + await page.reload(); + await harness().waitFor(); + await page + .getByRole("button", { name: "Select model, current Other Alternate", exact: true }) + .waitFor(); + assert.deepEqual(errors, []); + }, + ); +} + it( "a phone can send, reconnect, and reopen durable conversation history", { timeout: 60_000 }, diff --git a/packages/web-server/test/distribution/pack.test.ts b/packages/web-server/test/distribution/pack.test.ts index c1cd1b884..428df9a56 100644 --- a/packages/web-server/test/distribution/pack.test.ts +++ b/packages/web-server/test/distribution/pack.test.ts @@ -47,10 +47,14 @@ it( id: "fake", name: "Fake Harness", entry: "./plugin.mjs", - icon: "./assets/icon.svg", + icon: "./assets/icon.png", }), ); - writeFileSync(join(plugin, "assets/icon.svg"), ''); + const icon = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a0uoAAAAASUVORK5CYII=", + "base64", + ); + writeFileSync(join(plugin, "assets/icon.png"), icon); const output = join(root, "output"); await exec( process.execPath, @@ -68,7 +72,10 @@ it( const index = await fetch(url); assert.equal(index.status, 200); assert.match(await index.text(), /CodexHost/u); - assert.equal((await fetch(new URL("harness-icons/fake", url))).status, 200); + const iconResponse = await fetch(new URL("harness-icons/fake", url)); + assert.equal(iconResponse.status, 200); + assert.equal(iconResponse.headers.get("content-type"), "image/png"); + assert.deepEqual(Buffer.from(await iconResponse.arrayBuffer()), icon); const catalog = await fetch(new URL("api/session/modelCatalog", url), { method: "POST", headers: { "content-type": "application/json" }, @@ -131,5 +138,49 @@ it( ); assert.equal(received.args[received.args.indexOf("--data") + 1], join(preview, "data")); assert.equal(received.args.includes("--no-auth"), false); + + for (const [id, kind] of [ + ["pi", "session"], + ["codex", "session"], + ["opencode", "session"], + ["usage", "usage"], + ] as const) { + mkdirSync(join(preview, "adapters", id), { recursive: true }); + writeFileSync(join(preview, "adapters", id, "manifest.json"), JSON.stringify({ id, kind })); + } + const previewEnv = { + PATH: process.env.PATH, + HOME: root, + CODEXHOST_RUNTIME_TOKEN: "test-host-token", + }; + const { stdout: allStdout } = await exec( + process.execPath, + [join(preview, "preview.mjs"), "--harness", "all"], + { cwd: root, env: previewEnv }, + ); + const all = JSON.parse(allStdout) as { args: string[]; env: NodeJS.ProcessEnv }; + assert.equal(all.args[all.args.indexOf("--harness") + 1], "claude-code,opencode"); + assert.equal(all.args[all.args.indexOf("--host") + 1], "127.0.0.1"); + assert.equal(all.env.CODEXHOST_RUNTIME_TOKEN, undefined); + assert.equal(all.args.includes("--no-auth"), false); + assert.equal(all.args.includes("--import-recent"), false); + for (const id of ["pi", "codex"]) { + await assert.rejects( + exec(process.execPath, [join(preview, "preview.mjs"), "--harness", id], { + cwd: root, + env: previewEnv, + }), + /not allowed/u, + ); + } + for (const id of ["missing", "usage"]) { + await assert.rejects( + exec(process.execPath, [join(preview, "preview.mjs"), "--harness", id], { + cwd: root, + env: previewEnv, + }), + /bundled session Adapter/u, + ); + } }, ); diff --git a/packages/web-server/test/fake-harness/fake/icon.svg b/packages/web-server/test/fake-harness/fake/icon.svg new file mode 100644 index 000000000..db3f39964 --- /dev/null +++ b/packages/web-server/test/fake-harness/fake/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/web-server/test/fake-harness/fake/manifest.json b/packages/web-server/test/fake-harness/fake/manifest.json index 851e48ee8..c160f8d02 100644 --- a/packages/web-server/test/fake-harness/fake/manifest.json +++ b/packages/web-server/test/fake-harness/fake/manifest.json @@ -4,5 +4,6 @@ "name": "Fake Harness", "version": "0.0.0", "adapterApiVersion": 1, - "entry": "./plugin.ts" + "entry": "./plugin.ts", + "icon": "./icon.svg" } diff --git a/packages/web-server/test/fake-harness/fake/plugin.ts b/packages/web-server/test/fake-harness/fake/plugin.ts index 4ae2af6d0..519773092 100644 --- a/packages/web-server/test/fake-harness/fake/plugin.ts +++ b/packages/web-server/test/fake-harness/fake/plugin.ts @@ -45,9 +45,13 @@ class Channel { let nextSession = 0; class FakeSession { - readonly harnessId = "fake"; readonly capabilities = { - configuration: { selectModel: true, selectThinkingOption: true, selectPermissionMode: true }, + configuration: { + selectModel: true, + selectThinkingOption: true, + selectPermissionMode: true, + permissionModeScope: "live" as "live" | "atCreate", + }, history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, }; readonly initialUsage = null; @@ -57,11 +61,18 @@ class FakeSession { private responses = new Map void>(); private cancel: (() => void) | undefined; - constructor(readonly nativeId: string) { + constructor( + readonly nativeId: string, + readonly harnessId = "fake", + modelId = "fake-model", + permissionModeId = "ask", + scope: "live" | "atCreate" = "live", + ) { + this.capabilities.configuration.permissionModeScope = scope; this.initialState = { - nativeRef: { harnessId: "fake", nativeSessionId: nativeId }, - effectiveModel: { id: "fake-model" }, - effectivePermissionModeId: "ask", + nativeRef: { harnessId, nativeSessionId: nativeId }, + effectiveModel: { id: modelId }, + effectivePermissionModeId: permissionModeId, }; } @@ -93,11 +104,28 @@ class FakeSession { async execute(command: Record & { type: string }): Promise { globalThis.fakeHarnessLog?.push({ command: command.type, + ...(command.type === "permissionMode.select" + ? { permissionModeId: command.permissionModeId } + : {}), ...(command.type === "turn.start" ? { text: (command.input as Array<{ text: string }>)[0]?.text } : {}), }); switch (command.type) { + case "permissionMode.select": + if (command.permissionModeId === "reject-native") + return { + ok: false, + error: { code: "nativeRejected", message: "Native permission refusal" }, + }; + this.channel.emit({ + kind: "event", + event: { + type: "session.state.changed", + state: { effectivePermissionModeId: command.permissionModeId }, + }, + }); + return { ok: true, value: {} }; case "turn.start": { const text = (command.input as Array<{ text: string }>).map((part) => part.text).join("\n"); const steps = globalThis.fakeHarnessScripts?.[text] ?? [ @@ -149,40 +177,66 @@ class FakeSession { } } -export function createHarnessAdapter(): unknown { +export function createHarnessAdapter({ + harnessId = "fake", + modelId = "fake-model", + modelLabel = "Fake Model", + permissionModes = { + modes: [ + { id: "ask", label: "Ask" }, + { id: "yolo", label: "Yolo", description: "Skip native permission checks", dangerous: true }, + { id: "reject-native", label: "Rejected mode" }, + ], + defaultModeId: "ask", + }, + permissionModeScope = "live", +}: { + harnessId?: string; + modelId?: string; + modelLabel?: string; + permissionModes?: { + modes: Array<{ id: string; label: string; description?: string; dangerous?: boolean }>; + defaultModeId: string; + }; + permissionModeScope?: "live" | "atCreate"; +} = {}) { return { - harnessId: "fake", + harnessId, async inspect() { return { status: "ready", catalog: { - models: [{ ref: { id: "fake-model" }, label: "Fake Model" }], - defaultModel: { id: "fake-model" }, + models: [{ ref: { id: modelId }, label: modelLabel }], + defaultModel: { id: modelId }, thinkingOptions: [], }, - permissionModes: { - modes: [ - { id: "ask", label: "Ask" }, - { id: "yolo", label: "Yolo" }, - ], - defaultModeId: "ask", - }, + permissionModes, capabilities: { configuration: { selectModel: true, selectThinkingOption: false, selectPermissionMode: true, + permissionModeScope, }, history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, }, }; }, - async open(input: { kind: string; nativeRef?: { nativeSessionId: string } }) { + async open(input: { + kind: string; + nativeRef?: { nativeSessionId: string }; + model?: { id: string }; + permissionModeId?: string; + }) { globalThis.fakeHarnessLog?.push({ open: input.kind }); return { ok: true, value: new FakeSession( - input.nativeRef?.nativeSessionId ?? `fake-native-${String(++nextSession)}`, + input.nativeRef?.nativeSessionId ?? `${harnessId}-native-${String(++nextSession)}`, + harnessId, + input.model?.id ?? modelId, + input.permissionModeId ?? permissionModes.defaultModeId, + permissionModeScope, ), }; }, diff --git a/packages/web-server/test/fake-harness/other/icon.svg b/packages/web-server/test/fake-harness/other/icon.svg new file mode 100644 index 000000000..db3f39964 --- /dev/null +++ b/packages/web-server/test/fake-harness/other/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/web-server/test/fake-harness/other/manifest.json b/packages/web-server/test/fake-harness/other/manifest.json new file mode 100644 index 000000000..cf24f76f7 --- /dev/null +++ b/packages/web-server/test/fake-harness/other/manifest.json @@ -0,0 +1,6 @@ +{ + "id": "other", + "name": "Other Harness", + "entry": "./plugin.ts", + "icon": "./icon.svg" +} diff --git a/packages/web-server/test/fake-harness/other/plugin.ts b/packages/web-server/test/fake-harness/other/plugin.ts new file mode 100644 index 000000000..b9b7f3d26 --- /dev/null +++ b/packages/web-server/test/fake-harness/other/plugin.ts @@ -0,0 +1,30 @@ +/** A second scripted identity for Harness/model separation browser acceptance. */ +import { createHarnessAdapter as createFake } from "../fake/plugin.ts"; + +export function createHarnessAdapter() { + const adapter = createFake({ + harnessId: "other", + modelId: "other-model", + modelLabel: "Other Model", + permissionModeScope: "atCreate", + permissionModes: { + modes: [ + { id: "auto", label: "Native Auto", description: "Native classifier, not a DSH review." }, + { + id: "other-full", + label: "Other Full", + description: "Native unrestricted execution.", + dangerous: true, + }, + ], + defaultModeId: "auto", + }, + }); + const inspect = adapter.inspect; + adapter.inspect = async () => { + const inspection = await inspect(); + inspection.catalog.models.push({ ref: { id: "other-alternate" }, label: "Other Alternate" }); + return inspection; + }; + return adapter; +} diff --git a/packages/web-server/test/preview-launch.test.ts b/packages/web-server/test/preview-launch.test.ts index 64feddd30..4494844b6 100644 --- a/packages/web-server/test/preview-launch.test.ts +++ b/packages/web-server/test/preview-launch.test.ts @@ -45,6 +45,34 @@ it("removes inherited Host routing and Node injection without changing the paren assert.equal(args.includes("--import-recent"), false); }); +it("accepts explicit Harness ids without relaxing isolation", () => { + const launch = previewLaunch( + "/packed", + { harness: "claude-code, opencode,claude-code" }, + { CODEXHOST_IMPORT_RECENT: "10", NODE_PATH: "/unsafe" }, + ); + assert.equal(launch.args[launch.args.indexOf("--harness") + 1], "claude-code,opencode"); + assert.deepEqual(launch.env, {}); + assert.equal(launch.args.includes("--no-auth"), false); + assert.equal(launch.args.includes("--import-recent"), false); +}); + +it("rejects Pi, Codex, empty ids and unsafe selection syntax before spawning", () => { + for (const harness of [ + "pi", + "codex", + "claude-code,pi", + "claude-code, codex", + "", + "claude-code,", + "../other", + "--no-auth", + "PI", + ]) { + assert.throws(() => previewLaunch("/packed", { harness }, {}), /not allowed/u); + } +}); + it("passes only isolated settings to the actual child process", async (t) => { const root = mkdtempSync(join(tmpdir(), "codexhost-preview-")); t.after(() => { @@ -56,7 +84,12 @@ it("passes only isolated settings to the actual child process", async (t) => { ); const launch = previewLaunch( root, - { port: "0", data: join(root, "data"), workspace: join(root, "workspace") }, + { + port: "0", + data: join(root, "data"), + workspace: join(root, "workspace"), + harness: "claude-code,opencode", + }, { PATH: process.env.PATH, HOME: root, @@ -77,6 +110,6 @@ it("passes only isolated settings to the actual child process", async (t) => { ); assert.equal(received.env.NODE_OPTIONS, undefined); assert.equal(received.env.NODE_PATH, undefined); - assert.equal(received.args[received.args.indexOf("--harness") + 1], "claude-code"); + assert.equal(received.args[received.args.indexOf("--harness") + 1], "claude-code,opencode"); assert.equal(received.args[received.args.indexOf("--data") + 1], join(root, "data")); }); diff --git a/packages/web-server/test/session-presentation.test.ts b/packages/web-server/test/session-presentation.test.ts new file mode 100644 index 000000000..45003fc1d --- /dev/null +++ b/packages/web-server/test/session-presentation.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import type { Inspection } from "../src/harnesses.ts"; +import { nativePermissionView } from "../src/session-presentation.ts"; + +const ready: Extract = { + status: "ready", + catalog: { models: [], thinkingOptions: [] }, + permissionModes: { + modes: [ + { id: "auto", label: "Native Auto", description: "Native classifier", dangerous: true }, + ], + defaultModeId: "auto", + }, + capabilities: { + configuration: { selectModel: false, selectThinkingOption: false, selectPermissionMode: true }, + history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, + }, +}; +it("preserves native labels, descriptions and danger flags, not DSH auto semantics", () => { + const view = nativePermissionView("harness", ready, true); + assert.equal(view.catalog, ready.permissionModes); + assert.equal(view.locked, false); + assert.equal(view.catalog?.modes[0]?.label, "Native Auto"); +}); +it("does not invent default permission controls for unsupported or unavailable Harnesses", () => { + for (const inspection of [ + { + ...ready, + permissionModes: undefined, + capabilities: { + ...ready.capabilities, + configuration: { ...ready.capabilities.configuration, selectPermissionMode: false }, + }, + }, + { status: "unavailable", error: { message: "Unavailable" } }, + ] as Inspection[]) { + const view = nativePermissionView("harness", inspection, false); + assert.equal(view.selectable, false); + assert.equal(view.catalog, null); + } +}); +it("creation scope and live session capability changes remain authoritative", () => { + assert.equal( + nativePermissionView("harness", ready, false, { + selectPermissionMode: true, + permissionModeScope: "atCreate", + }).locked, + false, + ); + assert.equal( + nativePermissionView("harness", ready, true, { + selectPermissionMode: true, + permissionModeScope: "atCreate", + }).locked, + true, + ); + assert.equal( + nativePermissionView("harness", ready, true, { selectPermissionMode: false }).selectable, + false, + ); +}); diff --git a/packages/web-server/test/sessions.integration.test.ts b/packages/web-server/test/sessions.integration.test.ts index 368c186dd..1d37377a7 100644 --- a/packages/web-server/test/sessions.integration.test.ts +++ b/packages/web-server/test/sessions.integration.test.ts @@ -290,7 +290,7 @@ describe("Sessions with a scripted Harness", () => { ); assert.deepEqual( modes.options.map((option) => option.value), - ["ask", "yolo"], + ["ask", "yolo", "reject-native"], ); const result = await bench.call<{ result: { kind: string } }>("commands/execute", { agentId: sessionId, @@ -312,6 +312,48 @@ describe("Sessions with a scripted Harness", () => { assert.equal(rejected.ok, false); }); + it("keeps native catalogs session-scoped, forwards live IDs and rejects creation-only changes", async () => { + const fakeId = await newSession(); + await prompt(fakeId, "native live"); + await waitFor(() => turnEnds(fakeId).length === 1, "live session"); + await bench.call("commands/execute", { agentId: fakeId, line: "/permission yolo" }); + assert.ok( + defined(globalThis.fakeHarnessLog).some( + (entry) => entry.command === "permissionMode.select" && entry.permissionModeId === "yolo", + ), + ); + const otherId = await newSession(); + await bench.call("session/selectModel", { + request: { sessionId: otherId, provider: "other", model: "other-model" }, + }); + const projection = (id: string) => + bench.data.readJson>(`sessions/${id}/projections.json`, {}); + assert.deepEqual(projection(fakeId).harnessIdentity, { id: "fake", name: "Fake Harness" }); + assert.deepEqual(projection(otherId).harnessIdentity, { id: "other", name: "Other Harness" }); + const other = projection(otherId).nativePermissions as { + locked: boolean; + catalog: { modes: Array<{ id: string; label: string; dangerous?: boolean }> }; + }; + assert.equal(other.locked, false); + assert.equal(other.catalog.modes[0]?.label, "Native Auto"); + assert.equal(other.catalog.modes[1]?.dangerous, true); + await prompt(otherId, "native creation"); + await waitFor(() => turnEnds(otherId).length === 1, "creation-only session"); + const refused = await bench.call<{ result: { kind: string } }>("commands/execute", { + agentId: otherId, + line: "/permission other-full", + }); + assert.equal(refused.result.kind, "error"); + const fakeCatalog = await bench.call<{ options: Array<{ value: string }> }>( + "permissionPresets/catalog", + { sessionId: fakeId }, + ); + assert.deepEqual( + fakeCatalog.options.map((mode) => mode.value), + ["ask", "yolo", "reject-native"], + ); + }); + it("rejects attachments instead of dropping them", async () => { const sessionId = await newSession(); const result = await bench.rpc.dispatch("session/prompt", { From f5fcd7d1b7f487e622e333a6c92e24c691b86252 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 10 Oct 2026 13:38:08 +0800 Subject: [PATCH 003/106] feat: share CH threads and page history in web Use the existing Desktop Host for canonical external Threads without starting another Adapter or Mapping Store. Group navigation by GUI project membership while preserving explicit Web workspaces.\n\nLoad the latest five Turns first, prefetch one older page, preserve rendering anchors, and reuse idle history. Keep model and permission discovery off the history display path. Add shared-host, pagination, concurrency and browser coverage. --- README.md | 2 +- .../packages/api/remotes/src/remote-events.ts | 8 + .../client/ui-codexhost/src/client/index.ts | 14 +- docs/index.md | 2 +- docs/product/standalone-web.md | 30 +- package-lock.json | 5 +- packages/web-server/README.md | 6 +- packages/web-server/package.json | 1 + packages/web-server/scripts/preview.ts | 2 + packages/web-server/src/ch-harness-catalog.ts | 89 +++ packages/web-server/src/ch-host-client.ts | 163 +++++ packages/web-server/src/ch-project-groups.ts | 29 + packages/web-server/src/ch-sessions.ts | 597 ++++++++++++++++++ packages/web-server/src/ch-thread-history.ts | 176 ++++++ packages/web-server/src/ch-thread-view.ts | 201 ++++++ packages/web-server/src/harnesses.ts | 2 +- packages/web-server/src/main.ts | 26 +- packages/web-server/src/misc.ts | 7 +- packages/web-server/src/preview-launch.ts | 5 + packages/web-server/src/projector.ts | 5 +- packages/web-server/src/session-log.ts | 44 +- packages/web-server/src/workspaces.ts | 103 ++- .../test/browser/ch-history.test.ts | 108 ++++ .../test/browser/ch-sessions.test.ts | 156 +++++ .../test/browser/native-presentation.test.ts | 2 +- .../test/ch-history-concurrency.test.ts | 45 ++ packages/web-server/test/ch-sessions.test.ts | 200 ++++++ .../web-server/test/ch-thread-history.test.ts | 156 +++++ packages/web-server/test/support/ch-host.ts | 284 +++++++++ packages/web-server/test/support/server.ts | 1 + packages/web-server/tsconfig.json | 1 + 31 files changed, 2431 insertions(+), 39 deletions(-) create mode 100644 packages/web-server/src/ch-harness-catalog.ts create mode 100644 packages/web-server/src/ch-host-client.ts create mode 100644 packages/web-server/src/ch-project-groups.ts create mode 100644 packages/web-server/src/ch-sessions.ts create mode 100644 packages/web-server/src/ch-thread-history.ts create mode 100644 packages/web-server/src/ch-thread-view.ts create mode 100644 packages/web-server/test/browser/ch-history.test.ts create mode 100644 packages/web-server/test/browser/ch-sessions.test.ts create mode 100644 packages/web-server/test/ch-history-concurrency.test.ts create mode 100644 packages/web-server/test/ch-sessions.test.ts create mode 100644 packages/web-server/test/ch-thread-history.test.ts create mode 100644 packages/web-server/test/support/ch-host.ts diff --git a/README.md b/README.md index 092d922f4..405779546 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ https://github.com/user-attachments/assets/c48192d7-23ff-4f6e-b61a-6345a655bb76 ## Standalone Web development -This checkout also contains an independent Web/PWA application using the same Harness Adapter sources. Run `npm ci`, `npm run build:web`, then `npm run start:web` for the isolated Claude Code preview. This does not launch or restart Codex Desktop. See [the Web guide](docs/product/standalone-web.md) for plugin distribution, storage isolation and current limitations. +This checkout also contains a Web/PWA application. Run `npm ci`, `npm run build:web`, then `npm run start:web` for an authenticated Web client of the already-running local codexhost Host. It reuses canonical CH Threads and groups them by their existing GUI project membership without creating GUI projects. This does not launch or restart Desktop. `-- --session-source standalone` explicitly selects the independent Harness plugin mode. See [the Web guide](docs/product/standalone-web.md) for connection requirements, plugin distribution and current limitations. ## Quick Start diff --git a/apps/web-ui/packages/api/remotes/src/remote-events.ts b/apps/web-ui/packages/api/remotes/src/remote-events.ts index 7dc9341b1..651cd344d 100644 --- a/apps/web-ui/packages/api/remotes/src/remote-events.ts +++ b/apps/web-ui/packages/api/remotes/src/remote-events.ts @@ -17,7 +17,15 @@ import type { TypertForwardableEventEntry } from "@deepseek-ai/dsh-typert-protoc * Host events this application forwards without renaming. The explicit mode is * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. */ +declare module "@deepseek-ai/cordis" { + interface Events { + /** Replace a browser-only draft with the canonical Thread returned by the existing CH Host. */ + "codexhost/session-bound"(draftId: string, threadId: string): void; + } +} + export const API_REMOTE_FORWARDED_EVENTS = [ + { event: "codexhost/session-bound", mode: "emit" }, { event: "agent-preset/selected", mode: "emit" }, { event: "approval/request", mode: "waterfall" }, { event: "api-session/activity", mode: "emit" }, diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts index a2ea64df2..88b1e8926 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts @@ -14,6 +14,7 @@ import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; import { ImportIcon } from "./ImportIcon.tsx"; import type {} from "@deepseek-ai/dsh-api-session-controller/client"; import { SessionHarnessIcon } from "./SessionHarnessIcon.tsx"; +import type {} from "@deepseek-ai/dsh-api-remotes/client"; import { ensureServiceWorker } from "./notifications.ts"; import { NotificationsRow } from "./NotificationsRow.tsx"; import { ImportPage, type ImportPageFace } from "./ImportPage.tsx"; @@ -33,7 +34,7 @@ export const NS = "codexhostImport"; export const PANEL_ID = "codexhost-import" as MainPanelId; /** Services used by the panel. */ -export const inject = ["slots", "locale", "layout", "uiWorkspace", "sessions"]; +export const inject = ["slots", "locale", "layout", "uiWorkspace", "sessions", "remote"]; /** * Contribute the sidebar entry and its main-column page. @@ -93,6 +94,17 @@ export function apply(ctx: ClientContext): void { ), ); + ctx.effect( + () => + ctx.remote.$on("codexhost/session-bound", (draftId, threadId) => { + if ( + (ctx.sessions.list.getSnapshot().byId[draftId as SessionId]?.retainedBy.mainView ?? 0) > 0 + ) + ctx.uiWorkspace.openSession(threadId as SessionId); + }), + "ui-codexhost: canonical CH identity", + ); + // Deep links (`?session=`, used by notification clicks) and service-worker messages. ensureServiceWorker(); const openFromUrl = (): void => { diff --git a/docs/index.md b/docs/index.md index 3ea60544f..21df4d1d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,7 +100,7 @@ | 文档 | 内容与阅读时机 | | --- | --- | -| [`product/standalone-web.md`](product/standalone-web.md) | 同仓库独立 Web/PWA 的源码构建、插件复用、启动隔离、存储及验收限制;开发 Web 或与 Desktop 并行运行时阅读。 | +| [`product/standalone-web.md`](product/standalone-web.md) | 同仓库 Web/PWA 的共享 CH 会话来源、GUI 项目归组、历史分页、独立模式、源码构建、启动隔离及验收限制;开发 Web 或与 Desktop 并行运行时阅读。 | ## 账号与 Desktop 产品接入 diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index 7cf53e32f..bd2e68d67 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -1,6 +1,6 @@ # 独立 Web 应用 -Web 与 Codex Desktop 集成在同一仓库开发,但分别运行。浏览器连接 `packages/web-server` 的 Node 服务;服务直接加载 Harness 插件,不连接 Desktop Host,也不使用 Desktop 的所有权记录、映射数据库或启动器。Web/PWA 共用响应式界面;尚无 iOS/Android 原生封装。 +Web 与 Codex Desktop 在同一仓库开发,浏览器通过 `packages/web-server` 的独立 Node 服务访问。默认 `codexhost` 会话来源连接已经运行的本地 CH Host,复用 CH 的 Thread 目录、创建、读取和操作;不直接读写 Mapping Store、不启动第二份 Adapter,也不登记 Desktop 进程所有权。显式 `--session-source standalone` 保留独立 Harness 插件模式。Web/PWA 共用响应式界面;尚无 iOS/Android 原生封装。 ## 代码与构建 @@ -20,7 +20,29 @@ npm run start:web -- --port 3181 `build:web` 编译当前工作树的 CH 包并调用已有插件构建器,随后编译前端和打包。插件输入为 `packages/host-runtime/dist/plugins`,不是已安装的 CH.app 或另一个仓库。用量统计专用插件不作为聊天 Harness;Web 另行打包同仓库的 Codex Adapter。 -发行目录为 `packages/web-server/dist/codexhost-web`,包含 Node 服务、Web 界面和自包含 Adapter。整体目录可以移动到仓库外运行,无需 CH.app、Codex Desktop、源码目录或仓库的 node_modules;Harness CLI 和其认证仍由用户安装、配置。 +发行目录为 `packages/web-server/dist/codexhost-web`,包含 Node 服务、Web 界面和自包含 Adapter。整体目录可以移动到仓库外运行,无需源码目录或仓库的 node_modules。显式 standalone 模式无需 CH.app 或 Codex Desktop;默认共享模式仍需要已经运行的 codexhost Desktop Host。Harness CLI 和其认证仍由用户安装、配置。 + +## 共享 CH 会话来源 + +默认启动查询现有 Desktop 中的版本化 `__codexhostHostRoutingV1`,通过已安装的本机 CDP 控制通道访问 `local` Host 的现有请求接口,不重新注入 Renderer、不修改 CH、不启动/重启 Desktop。自动发现只读取现有 Codex 进程的调试端口参数;也可传 `--ch-cdp http://127.0.0.1:`。Windows 当前要求显式端点。Host 离线时报告不可用,不自动转为独立模式。 + +只展示 `modelProvider: codexhost` 且 Host 确认属于 external 的 Threads,包括已归档记录;不接入官方 Codex Threads或远程 Hosts。会话身份保持 CH Thread ID;列表、完整历史、模型和权限均从同一个 Host 查询。Web 不新增会话索引、原生引用映射或历史文件,DSH 格式只作可丢弃的内存展示缓冲。列表按普通刷新/定时查询更新,打开的历史按快照查询更新,没有新增持久同步队列或重放协议。 + +### 历史首屏与按需分页 + +按已核实的 Codex 本地分页路径,首次打开先通过 `thread/read(includeTurns: false)` 读取元数据,再请求 `thread/turns/list` 的最新 **5 轮**(`sortDirection: "desc"`、`itemsView: "full"`),按时间顺序显示;不等待全部历史,不调用 `thread/resume`,也不把工具/推理内容裁成摘要。Thread 归属由列表的 ownership 查询确认,运行态检查、模型与权限配置异步补入,不阻塞正文。 + +首屏 snapshot 发出后,仅后台预取 **1 页**较早历史,不自动全量遍历。向上翻页/点击“加载更早”通过 CH 的原生 cursor 补一页;前台与预取共用在途请求,失败不推进 cursor。头部刷新与旧页请求独立执行,慢旧页不阻塞新消息。重新打开复用已加载窗口,不因多客户端打开而继续后台遍历。 + +为兼容现有 Web 事件协议,内存展示日志使用从 `2 ** 40` 起始的非负局部序号/Turn 坐标,向前插入时向小序号扩展、向后追加时递增;它们不是原生 Turn 编号,不持久化、不传给 CH、不代表未加载的消息数。旧页插入不重编号现有消息、工具引用或滚动锚点;范围耗尽明确报错。独立模式持久日志仍从零开始。界面复用原有消息组件、折叠工具详情和分页滚动锚点,不复制 Codex 私有渲染器或状态存储。 + +空闲窗口的普通读取最多 30 秒直接复用;列表检测到更新时间/运行状态变化时提前校验。校验先读取轻量元数据;未变化的空闲历史避免重复拉取正文,每 60 秒仍校验一次最近 5 轮,以覆盖 Host 更新时间秒级精度内的变更。运行中读取最近页;如果新增轮数超过 5,继续读到已知尾部,避免中间漏轮。Web 写操作后强制刷新。重复 cursor、跨页重复 Turn、历史回滚导致原尾部消失均报错,不伪造连续性。当前仍为快照查询而非完整 Host 实时事件订阅;同一秒内的外部修改若未改变 Host 更新时间和观察到的运行状态,最迟在后续尾页校验中检测,不保证立即同步。所有历史缓冲仍仅在内存中。 + +新会话在发送前只是浏览器草稿,Harness/模型/权限可选择;首次发送通过 CH `thread/start` 创建并使用其 canonical Thread ID,然后通过 `turn/start` 提交,同样保存到 CH 原有会话目录。草稿切换为 canonical ID 时仅导航仍查看该草稿的浏览器,不跳转其他客户端。创建或发送结果未知时不自动重试写操作。Web 不执行项目创建请求、不设置 `projectId`。 + +执行 `cwd` 不等于用户选择了项目。Web 只读 GUI 已有的本地项目目录、Thread 项目归属与未选项目标记,按实际项目根目录补齐导航条目和项目名称;已关联项目的 worktree 归到所属项目,实际执行 `cwd` 保持不变。未选项目的会话保留,但其自动生成的目录不单独列为项目;历史临时目录也不凭路径名称猜测项目。Web 明确选择并实际提交的工作区仍保留。项目元数据不可读取时报错,不回退到全部 `cwd`;仍不创建 GUI 项目、不设置 `projectId`、不执行 `mkdir`。复用已有 `workspaces.json`,旧版本误生成的导航条目仅在共享视图隐藏,不删除记录、会话或物理目录。已经失效的真实项目目录仍能显示历史入口。现有独立模式的历史文件保持原样,不自动迁入共享来源。 + +当前共享入口保留文本发送、停止、原生模型/权限选择及改名;需要审批的请求由现有 GUI 处理。Web 未接入共享 Host 的审批响应/实时通知、Steering 和完整子会话操作,不把这些入口包装成已经完成的实时双端同步。独立模式原有功能不受此限制。 ## Harness 与 Model 选择 @@ -38,7 +60,7 @@ Composer 的模型/推理等级控件旁提供独立的 Harness 图标按钮。 ## 并发运行与预览 -`start:web` 使用隔离的 Claude Code 预览入口,固定监听 localhost 并保留访问令牌认证。服务子进程不继承 `CODEXHOST_*`、`NODE_OPTIONS` 或 `NODE_PATH`。预览默认数据目录为 `~/.codexhost-web-preview`,工作区为 `~/codexhost-web-preview-workspace`;支持 `--port`、`--data`、`--workspace`,以及逗号分隔的 `--harness `。默认仅 Claude Code;`npm run start:web -- --harness all` 启用本发行包除 Codex、Pi 和用量统计专用插件外的所有会话插件。显式选择 Codex/Pi、缺失插件或用量统计插件会在启动前报错。 +`start:web` 固定监听 localhost 并保留访问令牌认证,默认连接已有本地 CH Host。独立模式通过 `--session-source standalone` 显式选择;其预览默认只启用 Claude Code。服务子进程不继承 `CODEXHOST_*`、`NODE_OPTIONS` 或 `NODE_PATH`。预览默认数据目录为 `~/.codexhost-web-preview`,工作区为 `~/codexhost-web-preview-workspace`;支持 `--port`、`--data`、`--workspace`,以及逗号分隔的 `--harness `。默认仅 Claude Code;`npm run start:web -- --harness all` 启用本发行包除 Codex、Pi 和用量统计专用插件外的所有会话插件。显式选择 Codex/Pi、缺失插件或用量统计插件会在启动前报错。 启用插件不等于机器已安装、认证对应 CLI,也不等于所有能力已完成 Web 验收。模型菜单只列检查就绪的 Harness;未安装的不会列为可用,检查失败由目录接口报告。Codex、Pi 的独立启动入口仍未验证,隔离预览始终拒绝启用;其他插件所需的可选 Host 上下文以及完整界面能力也需逐项验收。不要从 CH 承载的 Agent 会话直接启动继承原环境的 `server.mjs`,也不要把 Desktop 的环境变量恢复到预览进程。 @@ -53,6 +75,6 @@ npm run test:web:distribution npm run test:web:browser ``` -服务端和浏览器测试使用假 Harness 与临时数据,不提交真实模型请求。浏览器检查覆盖手机尺寸的设置、主题持久化、发送、断网重连和历史恢复;发行检查覆盖仓库外运行、插件资源和隔离启动参数。迁入的 Codex Item 映射测试由仓库 Vitest 配置运行。 +服务端和浏览器测试使用假 Harness、模拟 CH Host/CDP 与临时数据,不提交真实模型请求。共享来源检查覆盖 canonical 身份、已有 GUI 历史、Web 创建、自动目录分组和不新增会话文件;真实环境仅进行目录/元数据和页面只读检查。浏览器检查覆盖手机尺寸的设置、主题持久化、发送、断网重连和历史恢复;发行检查覆盖仓库外运行、插件资源和隔离启动参数。迁入的 Codex Item 映射测试由仓库 Vitest 配置运行。 CH 与 Web 后端纳入现有 TypeScript 检查;迁入前端由 tsdown/Vite 编译并接受浏览器回归,原 DSH 的完整前端类型检查与测试配置尚未迁入。自动化浏览器尺寸测试不替代真机键盘、后台生命周期和 HTTPS Web Push 验收。图片/附件仍被明确拒绝;没有把文件路径伪装成图片支持。 diff --git a/package-lock.json b/package-lock.json index cc910f3c3..91cef085b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,10 +51,12 @@ "license": "MIT", "devDependencies": { "@vitejs/plugin-react": "^4.0.0", + "immer": "^10.1.1", "lightningcss": "^1.32.0", "tsdown": "^0.22.2", "tsx": "4.22.4", - "vite": "^6.0.0" + "vite": "^6.0.0", + "zustand": "^4.4.7" } }, "apps/web-ui/node_modules/@esbuild/aix-ppc64": { @@ -11719,6 +11721,7 @@ "version": "0.0.0", "license": "LGPL-3.0-only", "dependencies": { + "@codexhost/desktop-control": "0.0.0", "@codexhost/harness-adapter": "0.0.0", "@codexhost/harness-plugin-files": "0.0.0", "@codexhost/shared-contracts": "0.0.0", diff --git a/packages/web-server/README.md b/packages/web-server/README.md index a8a28b126..3342580c3 100644 --- a/packages/web-server/README.md +++ b/packages/web-server/README.md @@ -1,11 +1,11 @@ # Web Server -An independent Node Web Host for the responsive DSH-derived UI. It loads the repository's self-contained Harness plugins through the public factory context and projects their events and interactions onto the existing browser protocol. It does not start, stop or connect to CodexHost Desktop. +A separate Node server for the responsive DSH-derived UI. By default it uses the running local CodexHost Desktop's existing versioned Host routing: canonical Threads, history and mutations remain owned by CH. It neither starts/stops Desktop nor opens a second Adapter or Mapping Store. Rendering journals are in memory; existing Web workspace records group CH Threads by their actual GUI project membership, not execution cwd alone. Unselected/generated directories stay out of the project list; assigned worktrees share their parent project while retaining their real cwd. Native project metadata is read-only, with no mkdir or GUI project creation. `--session-source standalone` explicitly selects the previous independent plugin Host. Build from the repository root with `npm run build:web`. The default plugin input is the same checkout's `packages/host-runtime/dist/plugins`, built by the existing CH release plugin builder; the installed CodexHost.app is not consulted. Usage-only plugins are excluded from conversations. The standalone Codex Adapter lives in `packages/adapters/codex`; no Harness implementation is duplicated here. -`npm run start:web` uses the isolated, authenticated preview, with Claude Code enabled by default. Add `-- --harness all` to enable every bundled session plugin except Codex/Pi, or `-- --harness ` to select specific plugins. Codex/Pi, missing plugins and usage-only plugins are rejected before starting; selection does not install/authenticate CLIs or imply full capability verification. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. +`npm run start:web` uses the isolated, authenticated preview and connects to the existing local CH Host. The Desktop must already be running through codexhost; an offline Host never falls back to a new independent session. Add `-- --ch-cdp http://127.0.0.1:` if automatic Desktop endpoint discovery is unavailable. For the independent plugin mode, use `-- --session-source standalone`, with Claude Code enabled by default. Add `-- --harness all` to enable every bundled session plugin except Codex/Pi, or `-- --harness ` to select specific plugins. Codex/Pi, missing plugins and usage-only plugins are rejected before starting; selection does not install/authenticate CLIs or imply full capability verification. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. -Node test-runner suites are owned by this package's `test`, `test:distribution` and `test:browser` scripts; the root TypeScript test command invokes the unit suites separately from Vitest. The browser and distribution suites require built frontend artifacts. They use temporary data and a fake Harness, never real model submissions. +Node test-runner suites are owned by this package's `test`, `test:distribution` and `test:browser` scripts; the root TypeScript test command invokes the unit suites separately from Vitest. The browser and distribution suites require built frontend artifacts. They use temporary data, fake Harnesses and a fake CH/CDP peer, never real model submissions. See the feature guide for shared-mode limitations, including GUI-owned approvals and polling rather than full live notification relay. The initial Web implementation is imported from the MIT-licensed `codexhost-web` fork; its upstream license is retained in `LICENSE.upstream`. Current behavior, limitations and ownership are documented in [standalone-web.md](../../docs/product/standalone-web.md). diff --git a/packages/web-server/package.json b/packages/web-server/package.json index d945417bf..afb13c914 100644 --- a/packages/web-server/package.json +++ b/packages/web-server/package.json @@ -14,6 +14,7 @@ "pack": "node --import tsx scripts/pack.ts" }, "dependencies": { + "@codexhost/desktop-control": "0.0.0", "@codexhost/harness-adapter": "0.0.0", "@codexhost/harness-plugin-files": "0.0.0", "@codexhost/shared-contracts": "0.0.0", diff --git a/packages/web-server/scripts/preview.ts b/packages/web-server/scripts/preview.ts index b6b09b3a0..fd281856a 100644 --- a/packages/web-server/scripts/preview.ts +++ b/packages/web-server/scripts/preview.ts @@ -8,6 +8,8 @@ import { PREVIEW_EXCLUDED_HARNESSES, previewLaunch } from "../src/preview-launch const { values } = parseArgs({ options: { + "session-source": { type: "string" }, + "ch-cdp": { type: "string" }, port: { type: "string" }, data: { type: "string" }, workspace: { type: "string" }, diff --git a/packages/web-server/src/ch-harness-catalog.ts b/packages/web-server/src/ch-harness-catalog.ts new file mode 100644 index 000000000..6f9dc50e4 --- /dev/null +++ b/packages/web-server/src/ch-harness-catalog.ts @@ -0,0 +1,89 @@ +/** Native plugin/model metadata from the same Host as the shared Threads, never local Adapter instances. */ +import { + harnessInspectionSchema, + harnessPluginListResultSchema, + type HarnessPluginDescriptor, + type HarnessInspection, +} from "@codexhost/shared-contracts"; +import type { ChHostClient } from "./ch-host-client.ts"; +import { groupOf, type Inspection } from "./harnesses.ts"; +export interface ModelSelection { + provider: string; + model: string; + reasoningEffort?: string; +} +import { RpcError } from "./transport.ts"; + +export class ChHarnessCatalog { + private plugins?: HarnessPluginDescriptor[]; + private inspections = new Map>(); + constructor(private readonly host: ChHostClient) {} + async list(): Promise { + this.plugins ??= harnessPluginListResultSchema + .parse(await this.host.request("codexhost/harness/plugins/list", {})) + .plugins.filter((plugin) => plugin.kind !== "usage"); + return this.plugins; + } + async inspect(id: string, cwd?: string): Promise { + const key = JSON.stringify([id, cwd]); + let inspection = this.inspections.get(key); + if (!inspection) { + inspection = this.host + .request("codexhost/harness/inspect", { + harnessId: id, + ...(cwd ? { cwd } : {}), + }) + .then((value) => harnessInspectionSchema.parse(value)) + .catch((error: unknown) => { + this.inspections.delete(key); + throw error; + }); + this.inspections.set(key, inspection); + } + return inspection; + } + async models() { + const plugins = await this.list(); + const values = await Promise.all( + plugins.map(async (plugin) => ({ plugin, inspection: await this.inspect(plugin.id) })), + ); + const groups = values.flatMap(({ plugin, inspection }) => + inspection.status === "ready" + ? [groupOf(plugin.id, plugin.name, inspection as Inspection & { status: "ready" })] + : [], + ); + const first = values.find( + (value) => value.inspection.status === "ready" && value.inspection.catalog.models.length, + ); + if (!first || first.inspection.status !== "ready") + throw new RpcError("host/no-harness", "CH has no ready session Harness."); + const selection: ModelSelection = { + provider: first.plugin.id, + model: + first.inspection.catalog.defaultModel?.id ?? + first.inspection.catalog.models[0]?.ref.id ?? + "", + }; + return { + default: selection, + groups, + routableProviders: groups.map((group) => group.id), + failures: values.flatMap(({ plugin, inspection }) => + inspection.status !== "ready" && inspection.status !== "notInstalled" + ? [{ id: plugin.id, name: plugin.name, message: inspection.error.message }] + : [], + ), + }; + } + async name(id: string): Promise { + return (await this.list()).find((plugin) => plugin.id === id)?.name ?? id; + } + async icon(id: string): Promise<{ body: Buffer; contentType: string } | undefined> { + const value = (await this.list()).find((plugin) => plugin.id === id)?.icon; + if (!value) return undefined; + const match = /^data:(image\/(?:png|jpeg|webp|svg\+xml));base64,(.+)$/u.exec(value); + return match?.[1] && match[2] + ? { body: Buffer.from(match[2], "base64"), contentType: match[1] } + : undefined; + } +} diff --git a/packages/web-server/src/ch-host-client.ts b/packages/web-server/src/ch-host-client.ts new file mode 100644 index 000000000..97c357e55 --- /dev/null +++ b/packages/web-server/src/ch-host-client.ts @@ -0,0 +1,163 @@ +/** Reach the already-running local CH Host through its installed, versioned Desktop routing. + * Never install Renderer code, launch Desktop, own a Mapping Store, or spawn a Harness. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { CdpClient, listCdpTargets } from "@codexhost/desktop-control"; +import { RpcError } from "./transport.ts"; + +export interface ChHostClient { + request(method: string, params: Record): Promise; + projects(threadIds: string[]): Promise; + close(): void; +} + +export interface ChProjectSnapshot { + projects: Array<{ id: string; name: string; rootPaths: string[] }>; + assignments: Record; + projectless: string[]; +} + +const exec = promisify(execFile); +const METHODS = new Set([ + "thread/list", + "thread/read", + "thread/turns/list", + "thread/start", + "thread/resume", + "thread/name/set", + "thread/archive", + "thread/unarchive", + "turn/start", + "turn/interrupt", + "codexhost/harness/plugins/list", + "codexhost/harness/inspect", + "codexhost/thread/inspect", + "codexhost/thread/ownership/list", + "codexhost/thread/model/select", + "codexhost/thread/thinking/select", + "codexhost/thread/permission-mode/select", + "codexhost/thread/commands/inspect", + "codexhost/thread/command/execute", + "codexhost/thread/fork", + "codexhost/harness/session-import/sources", + "codexhost/harness/session-import/list", + "codexhost/harness/session-import/import", +]); + +/** Discover only an existing Codex process's debugger flag; no process is launched or signalled. */ +async function desktopEndpoints(explicit?: string): Promise { + if (explicit) return [explicit]; + if (process.platform === "win32") + throw new RpcError( + "host/endpoint-required", + "Specify --ch-cdp for the running Desktop on Windows.", + ); + const { stdout } = await exec("ps", ["-axo", "command="], { maxBuffer: 4 * 1024 * 1024 }); + const endpoints = new Set(); + for (const line of stdout.split("\n")) { + if (!/\/(?:Codex|ChatGPT)\.app\//u.test(line)) continue; + const port = /--remote-debugging-port(?:=|\s+)(\d+)/u.exec(line)?.[1]; + if (port) endpoints.add(`http://127.0.0.1:${port}`); + } + return [...endpoints]; +} + +/** Connection loss does not retry a write: its outcome may already have applied. */ +export class DesktopChHostClient implements ChHostClient { + private client: CdpClient | undefined; + private connecting: Promise | undefined; + private closed = false; + constructor(private readonly endpoint?: string) {} + + private connect(): Promise { + if (this.closed) + return Promise.reject(new RpcError("host/offline", "The CH connection is closed.")); + if (this.client) return Promise.resolve(this.client); + this.connecting ??= this.discover().finally(() => { + this.connecting = undefined; + }); + return this.connecting; + } + + private async discover(): Promise { + for (const endpoint of await desktopEndpoints(this.endpoint)) { + const targets = await listCdpTargets(endpoint).catch(() => []); + for (const target of targets.filter((value) => value.type === "page")) { + const client = await CdpClient.connect(target.webSocketDebuggerUrl, { + commandTimeoutMs: 120_000, + }).catch(() => null); + if (!client) continue; + try { + const ready = await client.evaluate( + "!!window.__codexhostRendererBindingProbeV1 && (window.__codexhostHostRoutingV1?.knownHostIds?.() ?? []).includes('local')", + ); + if (ready && !this.closed) { + this.client = client; + return client; + } + } catch { + /* A detached or unrelated window is not the local Host. */ + } + client.close(); + } + } + throw new RpcError( + "host/offline", + "The local codexhost Desktop Host is unavailable. Start Codex through codexhost; Web will not create an independent session instead.", + ); + } + + async request(method: string, params: Record): Promise { + if (!METHODS.has(method)) + throw new RpcError("host/method-unavailable", `Unsupported CH method: ${method}`); + const client = await this.connect(); + // JSON literals only, never interpolate a path, model ID or user message as executable code. + const expression = `(async () => { const route = window.__codexhostHostRoutingV1?.forHost('local'); if (!route) return {ok:false,error:'Local Host unavailable'}; try { return {ok:true,value:await route.manager.sendRequest(${JSON.stringify(method)},${JSON.stringify(params)})}; } catch(error) { return {ok:false,error:String(error?.message ?? error)}; } })()`; + try { + const reply = await client.evaluate<{ ok: boolean; value?: T; error?: string }>(expression); + if (!reply.ok) + throw new RpcError("host/rejected", reply.error ?? "CH rejected the operation."); + return reply.value as T; + } catch (error) { + if (!(error instanceof RpcError)) { + this.client = undefined; + client.close(); + } + throw error instanceof RpcError + ? error + : new RpcError( + "host/disconnected", + "CH connection lost; the operation outcome may be unknown. Refresh before retrying.", + ); + } + } + + /** Read existing native project metadata; never save assignments or create GUI projects. */ + async projects(threadIds: string[]): Promise { + const client = await this.connect(); + const expression = `(async () => { + const manager = window.__codexhostHostRoutingV1?.forHost('local')?.manager; + const storage = manager?.threadWorkspaceStorage?.storage; + if (!storage?.readGlobalState) throw new Error('Native project metadata unavailable'); + const [local, order, assigned, projectlessIds] = await Promise.all(['local-projects','project-order','thread-project-assignments','projectless-thread-ids'].map(key => storage.readGlobalState(key))); + const ids = ${JSON.stringify(threadIds)}; + const projects = Object.values(local ?? {}).filter(project => Array.isArray(order) && order.includes(project.id)).map(project => ({id:project.id,name:project.name,rootPaths:project.rootPaths})); + return {projects,assignments:Object.fromEntries(ids.flatMap(id => assigned?.[id]?.projectKind === 'local' ? [[id,assigned[id].projectId]] : [])),projectless:ids.filter(id => Array.isArray(projectlessIds) && projectlessIds.includes(id))}; + })()`; + try { + return await client.evaluate(expression); + } catch { + throw new RpcError( + "host/projects-unavailable", + "Native project metadata is unavailable; execution cwd will not be guessed as a project.", + ); + } + } + + close(): void { + this.closed = true; + this.client?.close(); + this.client = undefined; + } +} diff --git a/packages/web-server/src/ch-project-groups.ts b/packages/web-server/src/ch-project-groups.ts new file mode 100644 index 000000000..23421e25a --- /dev/null +++ b/packages/web-server/src/ch-project-groups.ts @@ -0,0 +1,29 @@ +/** A native execution cwd is not proof that the user selected a project. */ +import { resolve, relative, isAbsolute, sep } from "node:path"; +import type { ChProjectSnapshot } from "./ch-host-client.ts"; + +/** Return the selected GUI project's root for navigation, never change execution cwd. + * @param snapshot Current native local projects and projectless classification. + * @param threadId Canonical CH Thread ID. + * @param cwd Actual execution directory. + * @returns Project group root, or undefined for unselected/generated directories. + */ +export function projectGroupRoot( + snapshot: ChProjectSnapshot, + threadId: string, + cwd: string, +): string | undefined { + const assigned = snapshot.assignments[threadId]; + const project = snapshot.projects.find((item) => item.id === assigned); + const roots = project?.rootPaths ?? snapshot.projects.flatMap((item) => item.rootPaths); + if (!project && snapshot.projectless.includes(threadId)) return undefined; + const matches = roots + .map((root) => resolve(root)) + .filter((root) => { + const path = relative(root, resolve(cwd)); + return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(".." + sep)); + }); + if (matches.length) return matches.sort((a, b) => b.length - a.length)[0]; + // An explicitly assigned worktree may live outside its parent project's root. + return project?.rootPaths[0]; +} diff --git a/packages/web-server/src/ch-sessions.ts b/packages/web-server/src/ch-sessions.ts new file mode 100644 index 000000000..e2382653d --- /dev/null +++ b/packages/web-server/src/ch-sessions.ts @@ -0,0 +1,597 @@ +/** CH-backed Web facade: canonical Threads are owned/persisted by CH, Web holds only rendering buffers. */ +import { randomUUID } from "node:crypto"; +import { + encodeHarnessPluginRoute, + harnessPluginRouteSchema, + threadInspectionSchema, +} from "@codexhost/shared-contracts"; +import type { ChHostClient } from "./ch-host-client.ts"; +import { ChHarnessCatalog, type ModelSelection } from "./ch-harness-catalog.ts"; +import type { SessionNotification } from "./sessions.ts"; +import type { ChThread } from "./ch-thread-view.ts"; +import type { Inspection } from "./harnesses.ts"; +import { nativePermissionView } from "./session-presentation.ts"; +import { projectGroupRoot } from "./ch-project-groups.ts"; +import { ChThreadHistory } from "./ch-thread-history.ts"; +import { requestOf } from "./store.ts"; +import { + type EventHub, + RpcError, + type RpcRegistry, + type StreamRegistry, + type StreamSink, +} from "./transport.ts"; +import type { Workspaces } from "./workspaces.ts"; +import { defaultProjections } from "./session-log.ts"; + +interface Draft { + id: string; + cwd: string; + selection: ModelSelection; + permissionModeId?: string; + createdAt: number; + canonicalId?: string; + creating?: Promise; +} + +export class ChSessions { + readonly catalog: ChHarnessCatalog; + private rows = new Map(); + private views = new Map(); + private readingViews = new Map>(); + private loadingControls = new Map>(); + private historyReadAt = new Map(); + private ownership = new Map(); + private drafts = new Map(); + private controls = new Set(); + private refreshing: Promise | undefined; + private timer?: NodeJS.Timeout; + private closed = false; + notifier: ((notification: SessionNotification) => void) | undefined; + constructor( + private readonly host: ChHostClient, + private readonly workspaces: Workspaces, + private readonly events: EventHub, + ) { + this.catalog = new ChHarnessCatalog(host); + } + + private id(id: string): string { + return this.drafts.get(id)?.canonicalId ?? id; + } + private async refresh(): Promise { + this.refreshing ??= this.listAll().finally(() => { + this.refreshing = undefined; + }); + return this.refreshing; + } + private async listAll(): Promise { + const found = new Map(); + for (const archived of [false, true]) { + let cursor: string | null = null; + const seen = new Set(); + do { + const page: { data: ChThread[]; nextCursor: string | null } = await this.host.request<{ + data: ChThread[]; + nextCursor: string | null; + }>("thread/list", { limit: 200, cursor, archived, modelProviders: ["codexhost"] }); + for (const row of page.data) + if (row.modelProvider === "codexhost") { + found.set(row.id, row); + } + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) + throw new RpcError("host/pagination-invalid", "CH cursor did not advance."); + if (cursor) seen.add(cursor); + } while (cursor); + } + const ownership = new Map(); + const ids = [...found.keys()]; + for (let start = 0; start < ids.length; start += 100) { + const result = await this.host.request<{ + threads: Array<{ threadId: string; owner: string; harnessId?: string }>; + }>("codexhost/thread/ownership/list", { threadIds: ids.slice(start, start + 100) }); + for (const item of result.threads) + if (item.owner === "external" && item.harnessId) + ownership.set(item.threadId, item.harnessId); + } + this.ownership = ownership; + this.rows = new Map([...found].filter(([id]) => ownership.has(id))); + const projects = await this.host.projects([...this.rows.keys()]); + const groups = new Map(); + for (const row of this.rows.values()) { + const root = row.cwd ? projectGroupRoot(projects, row.id, row.cwd) : undefined; + groups.set(row.id, root); + if (root) + this.workspaces.attachReferencedSession( + row.id, + root, + projects.projects.find((project) => project.rootPaths.includes(root))?.name, + ); + } + for (const draft of this.drafts.values()) groups.set(draft.id, draft.cwd); + this.workspaces.setReferenceGroups(groups); + } + private async owner(id: string) { + const inspection = threadInspectionSchema.parse( + await this.host.request("codexhost/thread/inspect", { threadId: this.id(id) }), + ); + if (inspection.owner !== "external") + throw new RpcError( + "session/not-external", + "This Web connection only handles external Harness Threads.", + ); + return inspection; + } + private view(id: string, reuseIdle = false): Promise { + id = this.id(id); + const existing = this.readingViews.get(id); + if (existing) return reuseIdle ? existing : existing.then(() => this.view(id)); + const cached = this.views.get(id); + const row = this.rows.get(id); + if ( + reuseIdle && + cached && + row && + row.status.type !== "active" && + cached.thread.status.type !== "active" && + row.updatedAt === cached.thread.updatedAt && + Date.now() - (this.historyReadAt.get(id) ?? 0) < 30_000 + ) + return Promise.resolve(cached); + const reading = this.readView(id, !reuseIdle).finally(() => this.readingViews.delete(id)); + this.readingViews.set(id, reading); + return reading; + } + private async readView(id: string, force: boolean): Promise { + if (!this.rows.has(id)) await this.refresh(); + const row = this.rows.get(id); + if (!row) throw new RpcError("session/not-found", "CH Thread not found.", { sessionId: id }); + const harnessId = this.ownership.get(id); + if (!harnessId) + throw new RpcError("session/not-external", "CH did not confirm external Thread ownership."); + let view = this.views.get(id); + if (!view) { + view = new ChThreadHistory(this.host, row, harnessId, (sessionId, key, value, seq) => { + for (const sink of this.controls) + sink.push({ type: "projection", sessionId, key, value, seq }); + }); + this.views.set(id, view); + } + await view.refresh(force); + this.historyReadAt.set(id, Date.now()); + view.log.setProjection("harnessIdentity", { + id: harnessId, + name: await this.catalog.name(harnessId), + }); + this.loadControls(view, harnessId, force); + return view; + } + private loadControls(view: ChThreadHistory, harnessId: string, force: boolean): void { + const id = view.thread.id; + if (!force && this.loadingControls.has(id)) return; + // Runtime inspection/config discovery must not gate the history snapshot. + const loading = Promise.all([this.owner(id), this.catalog.inspect(harnessId, view.thread.cwd)]) + .then(([owner, inspection]) => { + if (this.closed || this.loadingControls.get(id) !== loading) return; + view.log.setProjection( + "nativePermissions", + nativePermissionView(harnessId, inspection as Inspection, true), + ); + if (owner.effectivePermissionModeId) + view.log.setProjection("permissions", { currentValue: owner.effectivePermissionModeId }); + const selection = { + provider: harnessId, + model: owner.effectiveModel?.id ?? "harness", + ...(owner.effectiveThinkingOptionId + ? { reasoningEffort: owner.effectiveThinkingOptionId } + : {}), + }; + view.log.setProjection("modelSelection", { lastUsed: selection, next: selection }); + }) + .catch(() => { + // Controls remain absent on failure; history stays usable and later reads retry. + }) + .finally(() => { + if (this.loadingControls.get(id) === loading) this.loadingControls.delete(id); + }); + this.loadingControls.set(id, loading); + } + private async summary(row: ChThread) { + const harnessId = this.ownership.get(row.id); + if (!harnessId) + throw new RpcError("session/not-external", "CH did not confirm external Thread ownership."); + const values = { + ...defaultProjections(), + ...this.views.get(row.id)?.log.projections, + title: row.name ?? row.preview ?? null, + harnessIdentity: { id: harnessId, name: await this.catalog.name(harnessId) }, + sessionListMetadata: { blank: false, lastPromptAt: row.updatedAt * 1000 }, + }; + return { + sessionId: row.id, + cwd: row.cwd, + updatedAt: row.updatedAt * 1000, + running: row.status.type === "active", + blank: false, + agentAvailable: true, + ...(row.parentThreadId ? { parentSessionId: row.parentThreadId, origin: "subagent" } : {}), + projections: { kind: "cached", asOfSeq: 0, values }, + }; + } + private async draftSummary(draft: Draft) { + const inspection = await this.catalog.inspect(draft.selection.provider, draft.cwd); + return { + sessionId: draft.id, + cwd: draft.cwd, + updatedAt: draft.createdAt, + running: false, + blank: true, + agentAvailable: true, + projections: { + kind: "cached", + asOfSeq: 0, + values: { + ...defaultProjections(), + modelSelection: { lastUsed: null, next: draft.selection }, + harnessIdentity: { + id: draft.selection.provider, + name: await this.catalog.name(draft.selection.provider), + }, + nativePermissions: nativePermissionView( + draft.selection.provider, + inspection as Inspection, + false, + ), + permissions: { + currentValue: + draft.permissionModeId ?? + (inspection.status === "ready" + ? (inspection.permissionModes?.defaultModeId ?? "") + : ""), + }, + }, + }, + }; + } + private async create(args: Record) { + const request = requestOf<{ cwd?: string; workspaceId?: string; sessionId?: string }>(args); + const cwd = + request.cwd ?? + (request.workspaceId ? this.workspaces.get(request.workspaceId)?.path : undefined); + if (!cwd) throw new RpcError("workspace/invalid-path", "Choose a computer working directory."); + const id = request.sessionId ?? `draft-${randomUUID()}`; + const selection = (await this.catalog.models()).default; + const draft = { id, cwd, selection, createdAt: Date.now() }; + this.drafts.set(id, draft); + this.workspaces.attachReferencedSession(id, cwd); + this.workspaces.addDraftReference(id, cwd); + this.events.emit("api-session/added", await this.draftSummary(draft)); + return { sessionId: id, agentPreset: "standard" }; + } + private async commit(draft: Draft): Promise { + if (draft.canonicalId) return draft.canonicalId; + draft.creating ??= (async () => { + const result = await this.host.request<{ thread: ChThread }>("thread/start", { + cwd: draft.cwd, + model: encodeHarnessPluginRoute( + harnessPluginRouteSchema.parse({ + harnessId: draft.selection.provider, + model: { id: draft.selection.model }, + ...(draft.selection.reasoningEffort + ? { thinkingOptionId: draft.selection.reasoningEffort } + : {}), + ...(draft.permissionModeId ? { permissionModeId: draft.permissionModeId } : {}), + }), + ), + }); + draft.canonicalId = result.thread.id; + this.rows.set(result.thread.id, result.thread); + this.ownership.set(result.thread.id, (await this.owner(result.thread.id)).harnessId); + this.workspaces.selectReferenceWorkspace(draft.cwd); + this.workspaces.detachSession(draft.id); + this.workspaces.attachReferencedSession(result.thread.id, result.thread.cwd); + this.events.emit("api-session/added", await this.summary(result.thread)); + this.events.emit("codexhost/session-bound", draft.id, result.thread.id); + return result.thread.id; + })(); + // No automatic retry after creation failure: the Host may already have allocated an identity. + return draft.creating; + } + + register(rpc: RpcRegistry, streams: StreamRegistry): void { + rpc.register("session/list", async () => { + await this.refresh(); + return { + items: [ + ...(await Promise.all([...this.rows.values()].map((row) => this.summary(row)))), + ...(await Promise.all( + [...this.drafts.values()] + .filter((d) => !d.canonicalId) + .map((d) => this.draftSummary(d)), + )), + ], + }; + }); + rpc.register("session/create", (args) => this.create(args)); + rpc.register("session/modelCatalog", () => this.catalog.models()); + rpc.register("session/selectModel", async (args) => { + const request = requestOf(args); + const draft = this.drafts.get(request.sessionId); + if (draft && !draft.canonicalId) { + if (draft.creating) throw new RpcError("session/busy", "Draft creation is in progress."); + if (draft.selection.provider !== request.provider) delete draft.permissionModeId; + draft.selection = request; + this.events.emit("api-session/added", await this.draftSummary(draft)); + for (const sink of this.controls) { + const s = await this.draftSummary(draft); + for (const [key, value] of Object.entries(s.projections.values)) + sink.push({ type: "projection", sessionId: draft.id, key, value, seq: 0 }); + } + return {}; + } + const owner = await this.owner(request.sessionId); + if (owner.harnessId !== request.provider) + throw new RpcError( + "session/model-unavailable", + "Start a new conversation to change Harness.", + ); + await this.host.request("codexhost/thread/model/select", { + threadId: this.id(request.sessionId), + model: { id: request.model }, + }); + if (request.reasoningEffort) + await this.host.request("codexhost/thread/thinking/select", { + threadId: this.id(request.sessionId), + thinkingOptionId: request.reasoningEffort, + }); + await this.view(request.sessionId); + return {}; + }); + rpc.register("session/prompt", async (args) => { + const request = requestOf<{ + sessionId: string; + requestId?: string; + content: Array<{ type: string; text?: string }>; + mode?: string; + }>(args); + if (request.content.some((part) => part.type !== "text")) + throw new RpcError( + "session/attachment-invalid", + "Shared CH input currently accepts text only.", + ); + if (request.mode === "steer") + throw new RpcError( + "session/steer-unavailable", + "Use the existing CH controls to steer; Web will not cancel and resend implicitly.", + ); + const draft = this.drafts.get(request.sessionId); + const threadId = draft ? await this.commit(draft) : this.id(request.sessionId); + await this.owner(threadId); + await this.host.request("turn/start", { + threadId, + input: request.content, + ...(request.requestId ? { clientUserMessageId: request.requestId } : {}), + }); + this.events.emit("api-session/status", threadId, true); + await this.view(threadId); + return { accepted: true }; + }); + rpc.register("session/cancel", async (args) => { + const { sessionId } = requestOf<{ sessionId: string }>(args); + const view = await this.view(sessionId); + const turn = view.thread.turns.find((value) => value.status === "inProgress"); + if (turn) + await this.host.request("turn/interrupt", { + threadId: this.id(sessionId), + turnId: turn.id, + }); + return { accepted: true }; + }); + rpc.register("session/rename", async (args) => { + const request = requestOf<{ sessionId: string; title: string }>(args); + await this.owner(request.sessionId); + await this.host.request("thread/name/set", { + threadId: this.id(request.sessionId), + name: request.title, + }); + const view = await this.view(request.sessionId); + return { title: request.title, seq: view.log.lastSeq }; + }); + rpc.register("session/search", async (args) => { + await this.refresh(); + const needle = requestOf<{ query: string }>(args).query.toLowerCase(); + return { + items: [...this.rows.values()] + .filter((row) => (row.name ?? row.preview ?? "").toLowerCase().includes(needle)) + .slice(0, 20) + .map((row) => ({ sessionId: row.id, snippet: row.name ?? row.preview ?? "" })), + hasMore: false, + }; + }); + rpc.register("session/projections", async (args) => { + const { sessionId } = requestOf<{ sessionId: string }>(args); + const draft = this.drafts.get(sessionId); + return draft && !draft.canonicalId + ? (await this.draftSummary(draft)).projections + : (await this.view(sessionId, true)).log.projectionBaseline(); + }); + rpc.register("session/page", async (args) => { + const req = requestOf<{ + address: { sessionId: string }; + throughSeq: number; + beforeSeq?: number; + maxMessages?: number; + }>(args); + const view = + this.views.get(this.id(req.address.sessionId)) ?? + (await this.view(req.address.sessionId, true)); + return view.olderPage(req.throughSeq, req.beforeSeq, req.maxMessages); + }); + rpc.register("session/workspacePathApplications", () => ({ applications: [] })); + rpc.register("session/openWorkspacePath", () => { + throw new RpcError( + "session/open-unavailable", + "Opening computer paths is unavailable in Web.", + ); + }); + for (const [method, hostMethod] of [ + ["session/archive", "thread/archive"], + ["session/unarchive", "thread/unarchive"], + ]) + if (method && hostMethod) + rpc.register(method, async (args) => { + const req = requestOf<{ sessionId: string }>(args); + await this.owner(req.sessionId); + await this.host.request(hostMethod, { threadId: this.id(req.sessionId) }); + await this.refresh(); + return {}; + }); + streams.register("session/control", async (_args, sink) => { + this.controls.add(sink); + sink.onClose(() => this.controls.delete(sink)); + const projections: Record = {}; + for (const [id, view] of this.views) projections[id] = view.log.projectionBaseline(); + for (const draft of this.drafts.values()) + if (!draft.canonicalId) + projections[draft.id] = (await this.draftSummary(draft)).projections; + sink.push({ type: "baseline", value: { projections } }); + }); + streams.register("session/follow", async (args, sink) => { + const request = requestOf<{ address: { sessionId: string }; assistantStream?: boolean }>( + args, + ); + const draft = this.drafts.get(request.address.sessionId); + if (draft && !draft.canonicalId) { + const summary = await this.draftSummary(draft); + sink.push({ + type: "snapshot", + header: { + version: 4, + id: draft.id, + createdAt: draft.createdAt, + cwd: draft.cwd, + isSeeded: false, + agentPreset: "standard", + }, + cursor: -1, + records: [], + hasMore: false, + projections: summary.projections, + assistantStream: { revision: 0 }, + }); + return; + } + const view = await this.view(request.address.sessionId, true); + view.log.follow(sink, request); + // Publish first; prefetch just one older page, never drain all history. + const prefetch = setImmediate(() => { + if (!sink.closed && !this.closed) void view.prefetchOlder().catch(() => {}); + }); + sink.onClose(() => clearImmediate(prefetch)); + let busy = false; + const poll = setInterval(() => { + if (busy || sink.closed) return; + busy = true; + void this.view(view.thread.id, true) + .catch(() => sink.fail("host/disconnected", "CH is unavailable; refresh to reconnect.")) + .finally(() => { + busy = false; + }); + }, 1500); + poll.unref(); + sink.onClose(() => clearInterval(poll)); + }); + this.timer = setInterval(() => { + if (this.closed) return; + void this.refresh() + .then(async () => { + for (const row of this.rows.values()) + this.events.emit("api-session/added", await this.summary(row)); + }) + .catch(() => undefined); + }, 10000); + this.timer.unref(); + } + + registerCommands(rpc: RpcRegistry): void { + rpc.register("permissionPresets/catalog", () => ({ + options: [], + defaultOptions: [], + defaultPreset: "", + })); + rpc.register("commands/list", () => [ + { name: "permission", description: "Select a native permission mode" }, + { name: "model", description: "Select a native model" }, + ]); + rpc.register("commands/execute", async (args) => { + const id = String(args.agentId ?? ""); + const match = /^\/permission\s+(.+)$/u.exec(String(args.line ?? "")); + if (!match) return undefined; + const modeId = match[1] ?? ""; + const draft = this.drafts.get(id); + const harnessId = + draft && !draft.canonicalId ? draft.selection.provider : (await this.owner(id)).harnessId; + const inspection = await this.catalog.inspect( + harnessId, + draft?.cwd ?? this.rows.get(this.id(id))?.cwd, + ); + const mode = + inspection.status === "ready" + ? inspection.permissionModes?.modes.find((value) => value.id === modeId) + : undefined; + if (!mode) + throw new RpcError( + "session/permission-unavailable", + "Mode not advertised by this Harness.", + ); + if (draft && !draft.canonicalId) { + draft.permissionModeId = modeId; + for (const sink of this.controls) + sink.push({ + type: "projection", + sessionId: id, + key: "permissions", + value: { currentValue: modeId }, + seq: 0, + }); + } else { + await this.host.request("codexhost/thread/permission-mode/select", { + threadId: this.id(id), + permissionModeId: modeId, + }); + await this.view(id); + } + return { commandId: randomUUID(), result: { kind: "success", text: mode.label } }; + }); + } + registerImport(rpc: RpcRegistry): void { + rpc.register("codexhost/importSources", async () => { + const result = await this.host.request<{ + harnesses: Array<{ harnessId: string; name: string }>; + }>("codexhost/harness/session-import/sources", {}); + return result.harnesses; + }); + rpc.register("codexhost/importCandidates", (args) => + this.host.request("codexhost/harness/session-import/list", requestOf(args)), + ); + rpc.register("codexhost/import", async (args) => { + const result = await this.host.request<{ threadId: string }>( + "codexhost/harness/session-import/import", + requestOf(args), + ); + await this.refresh(); + return { sessionId: result.threadId }; + }); + } + async importRecent(limit: number): Promise { + void limit; + throw new RpcError("host/import-explicit", "CH imports must be explicitly selected."); + } + async close(): Promise { + this.closed = true; + clearInterval(this.timer); + this.host.close(); + for (const sink of this.controls) sink.end(); + } +} diff --git a/packages/web-server/src/ch-thread-history.ts b/packages/web-server/src/ch-thread-history.ts new file mode 100644 index 000000000..58385cbb8 --- /dev/null +++ b/packages/web-server/src/ch-thread-history.ts @@ -0,0 +1,176 @@ +/** Native CH pagination feeding a disposable, backwards-extensible Web journal. */ +import type { ChHostClient } from "./ch-host-client.ts"; +import { ChThreadView, type ChThread, type ChTurn } from "./ch-thread-view.ts"; +import type { ProjectionListener } from "./session-log.ts"; +import { RpcError } from "./transport.ts"; + +const PAGE_TURNS = 5; +// The Web wire protocol requires non-negative, stable rendering coordinates. Start +// its memory-only window in the middle of the safe integer range so older pages +// can prepend without renumbering visible messages, tool references or anchors. +// These are NOT native Turn numbers/cursors and are never persisted or sent to CH. +const RENDER_ORIGIN = 2 ** 40; +interface TurnsPage { + data: ChTurn[]; + nextCursor: string | null; +} + +export class ChThreadHistory extends ChThreadView { + private loaded = false; + private lastHeadReadAt = 0; + private prefetched = false; + private refreshing: Promise | undefined; + private loadingOlder: Promise | undefined; + private readonly seenCursors = new Set(); + private readonly pagination: { cursor: string | null }; + + constructor( + private readonly host: ChHostClient, + thread: ChThread, + harnessId: string, + onProjection: ProjectionListener, + ) { + const pagination = { cursor: null as string | null }; + super( + { ...thread, turns: [] }, + harnessId, + onProjection, + RENDER_ORIGIN, + RENDER_ORIGIN, + () => pagination.cursor !== null, + ); + this.pagination = pagination; + } + + private async page(cursor: string | null): Promise { + const result = await this.host.request("thread/turns/list", { + threadId: this.thread.id, + cursor, + limit: PAGE_TURNS, + sortDirection: "desc", + itemsView: "full", + }); + if ( + (result.nextCursor && result.nextCursor === cursor) || + (!result.data.length && result.nextCursor) + ) + throw new RpcError("host/history-invalid", "CH history cursor did not advance."); + if (new Set(result.data.map((turn) => turn.id)).size !== result.data.length) + throw new RpcError("host/history-invalid", "CH history repeated a Turn within a page."); + return result; + } + + /** First open reads only the newest five Turns. Later opens reuse this window; + * a metadata probe avoids rereading unchanged idle history, even after cache TTL. + */ + refresh(force = false): Promise { + this.refreshing ??= (async () => { + const { thread } = await this.host.request<{ thread: ChThread }>("thread/read", { + threadId: this.thread.id, + includeTurns: false, + }); + if ( + !force && + this.loaded && + Date.now() - this.lastHeadReadAt < 60_000 && + thread.updatedAt === this.thread.updatedAt && + thread.status.type !== "active" && + this.thread.status.type !== "active" + ) { + Object.assign(this.thread, { ...thread, turns: this.thread.turns }); + this.log.setProjection("title", thread.name ?? thread.preview ?? null); + return; + } + const recent = await this.page(null); + const descending = [...recent.data]; + const newestKnown = this.thread.turns.at(-1)?.id; + let cursor = recent.nextCursor; + const seen = new Set(); + // If multiple Turns arrived since last observation, fill the gap to the + // loaded tail. Never silently skip Turns just because the head page is small. + while ( + this.loaded && + newestKnown && + !descending.some((turn) => turn.id === newestKnown) && + cursor + ) { + if (seen.has(cursor)) + throw new RpcError("host/history-invalid", "CH history cursor did not advance."); + seen.add(cursor); + const more = await this.page(cursor); + descending.push(...more.data); + cursor = more.nextCursor; + } + if (new Set(descending.map((turn) => turn.id)).size !== descending.length) + throw new RpcError("host/history-invalid", "CH history repeated a Turn across pages."); + if (this.loaded && newestKnown && !descending.some((turn) => turn.id === newestKnown)) + throw new RpcError( + "host/history-changed", + "CH history was rewritten. Reload this page to reopen its current history.", + ); + if (!this.loaded) { + this.pagination.cursor = recent.nextCursor; + this.update({ ...thread, turns: descending.reverse() }); + this.loaded = true; + } else { + if (!newestKnown) this.pagination.cursor = recent.nextCursor; + const newTurns = newestKnown + ? descending.slice( + 0, + descending.findIndex((turn) => turn.id === newestKnown), + ) + : descending; + const incoming = new Map(descending.map((turn) => [turn.id, turn])); + const known = new Set(this.thread.turns.map((turn) => turn.id)); + this.update({ + ...thread, + turns: [ + ...this.thread.turns.map((turn) => incoming.get(turn.id) ?? turn), + ...newTurns.reverse().filter((turn) => !known.has(turn.id)), + ], + }); + } + this.lastHeadReadAt = Date.now(); + })().finally(() => { + this.refreshing = undefined; + }); + return this.refreshing; + } + + /** Warm one page once per loaded window, not once per browser subscriber. */ + async prefetchOlder(): Promise { + if (this.prefetched) return; + this.prefetched = true; + try { + await this.loadOlder(); + } catch (error) { + this.prefetched = false; + throw error; + } + } + + /** One older page, shared by foreground paging and bounded background prefetch. + * Failed requests do not advance the cursor; retry remains possible. + */ + loadOlder(): Promise { + this.loadingOlder ??= (async () => { + const cursor = this.pagination.cursor; + if (!cursor) return; + const page = await this.page(cursor); + if (page.nextCursor && this.seenCursors.has(page.nextCursor)) + throw new RpcError("host/history-invalid", "CH history cursor did not advance."); + this.prepend([...page.data].reverse()); + this.seenCursors.add(cursor); + this.pagination.cursor = page.nextCursor; + })().finally(() => { + this.loadingOlder = undefined; + }); + return this.loadingOlder; + } + + /** Extend only when the requested backwards boundary reaches native history. */ + async olderPage(throughSeq: number, beforeSeq: number | undefined, maxMessages?: number) { + if (beforeSeq !== undefined && beforeSeq <= this.log.firstSeq) await this.loadOlder(); + return this.log.page(throughSeq, beforeSeq, { ...(maxMessages ? { maxMessages } : {}) }); + } +} diff --git a/packages/web-server/src/ch-thread-view.ts b/packages/web-server/src/ch-thread-view.ts new file mode 100644 index 000000000..53be3bcc3 --- /dev/null +++ b/packages/web-server/src/ch-thread-view.ts @@ -0,0 +1,201 @@ +/** In-memory projection of the CH protocol onto the existing Web UI wire format. */ +import { SessionLog, type ProjectionListener } from "./session-log.ts"; +import { TurnProjector, type HostItem, type TurnOutcomeShape } from "./projector.ts"; + +export interface ChItem { + id: string; + type: string; + [key: string]: unknown; +} +export interface ChTurn { + id: string; + status: string; + items: ChItem[]; + error?: { message?: string }; +} +export interface ChThread { + id: string; + cwd: string; + modelProvider: string; + name?: string | null; + preview?: string; + createdAt: number; + updatedAt: number; + status: { type: string }; + turns: ChTurn[]; + parentThreadId?: string | null; + canAcceptDirectInput?: boolean; +} + +function itemOf(item: ChItem): HostItem | undefined { + const itemId = item.id; + switch (item.type) { + case "agentMessage": + return { type: "agentMessage", itemId, text: String(item.text ?? "") }; + case "reasoning": + return { + type: "reasoning", + itemId, + text: [ + ...(Array.isArray(item.summary) ? item.summary : []), + ...(Array.isArray(item.content) ? item.content : []), + ] + .filter((value) => typeof value === "string") + .join("\n"), + }; + case "commandExecution": + return { + type: "commandExecution", + itemId, + command: String(item.command ?? ""), + cwd: String(item.cwd ?? ""), + output: String(item.aggregatedOutput ?? ""), + exitCode: typeof item.exitCode === "number" ? item.exitCode : null, + }; + case "mcpToolCall": + case "dynamicToolCall": + return { + type: "toolExecution", + itemId, + toolName: String(item.tool ?? item.name ?? item.type), + arguments: item.arguments ?? {}, + output: { + content: [{ type: "text", text: JSON.stringify(item.result ?? item.contentItems ?? {}) }], + }, + }; + case "fileChange": + return { + type: "fileChange", + itemId, + changes: (Array.isArray(item.changes) ? item.changes : []).map( + (value: { path?: string; diff?: string; kind?: unknown }) => ({ + path: String(value.path ?? ""), + kind: typeof value.kind === "string" && value.kind === "delete" ? "delete" : "update", + unifiedDiff: String(value.diff ?? ""), + }), + ), + }; + case "contextCompaction": + return { type: "contextCompaction", itemId }; + default: + return undefined; + } +} +function finished(status: string): boolean { + return status !== "inProgress" && status !== "running"; +} +function outcome(turn: ChTurn): TurnOutcomeShape { + return turn.status === "failed" + ? { + status: "failed", + error: { code: "nativeFailed", message: turn.error?.message ?? "CH turn failed" }, + } + : turn.status === "interrupted" + ? { status: "cancelled" } + : { status: "succeeded" }; +} + +/** The journal is a disposable rendering buffer; every authoritative value remains in CH. */ +export class ChThreadView { + readonly log: SessionLog; + private turns = new Map< + string, + { projector: TurnProjector; items: Map; done: boolean } + >(); + constructor( + readonly thread: ChThread, + private readonly harnessId: string, + onProjection: ProjectionListener, + private turnStart = 0, + sequenceStart = 0, + hasEarlier: () => boolean = () => false, + ) { + this.log = new SessionLog( + { + version: 4, + id: thread.id, + createdAt: thread.createdAt * 1000, + cwd: thread.cwd, + isSeeded: false, + agentPreset: "standard", + }, + { + readLines: () => [], + readJson: (_path, fallback) => fallback, + writeLines: () => {}, + writeJson: () => {}, + appendLine: () => {}, + }, + onProjection, + sequenceStart, + hasEarlier, + ); + } + + /** Older native pages are projected separately, without changing the visible tail. */ + prepend(turns: ChTurn[]): void { + if (!turns.length) return; + if (turns.some((turn) => this.turns.has(turn.id) || !finished(turn.status))) + throw new Error("Older CH history overlaps the loaded window or contains an active Turn"); + const start = this.turnStart - turns.length; + if (start < 0) throw new Error("History rendering Turn range exhausted"); + const page = new ChThreadView({ ...this.thread, turns: [] }, this.harnessId, () => {}, start); + page.update({ ...this.thread, turns }); + this.log.prepend(page.log.events); + for (const [id, view] of page.turns) this.turns.set(id, view); + this.turnStart = start; + this.thread.turns = [...turns, ...this.thread.turns]; + } + + update(thread: ChThread): void { + Object.assign(this.thread, thread); + this.log.setProjection("title", thread.name ?? thread.preview ?? null); + this.log.setProjection("sessionListMetadata", { + blank: thread.turns.length === 0, + lastPromptAt: thread.updatedAt * 1000, + }); + for (const turn of thread.turns) { + let view = this.turns.get(turn.id); + if (!view) { + const projector = new TurnProjector(this.log, { + live: !finished(turn.status), + turn: this.turnStart + this.turns.size + 1, + cwd: thread.cwd, + model: { provider: this.harnessId, model: "harness" }, + }); + const input = turn.items + .filter((item) => item.type === "userMessage") + .flatMap((item) => (Array.isArray(item.content) ? item.content : [])); + projector.begin(input, undefined); + view = { projector, items: new Map(), done: false }; + this.turns.set(turn.id, view); + } + if (view.done) continue; + for (const item of turn.items) { + const projected = itemOf(item); + if (!projected) continue; + const old = view.items.get(item.id); + if (!old) { + view.projector.itemStarted(projected); + } else if ( + (projected.type === "agentMessage" || projected.type === "reasoning") && + (old.type === "agentMessage" || old.type === "reasoning") && + projected.text.startsWith(old.text) + ) { + const text = projected.text.slice(old.text.length); + if (text) view.projector.itemUpdated(item.id, { type: "text.append", text }); + } else if (projected.type === "commandExecution" && old.type === "commandExecution") { + const text = (projected.output ?? "").slice((old.output ?? "").length); + if (text) view.projector.itemUpdated(item.id, { type: "output.append", text }); + } + view.items.set(item.id, projected); + } + if (finished(turn.status)) { + for (const item of view.items.values()) + view.projector.itemCompleted(item, { status: "succeeded" }); + view.projector.finish(outcome(turn)); + view.done = true; + } + } + } +} diff --git a/packages/web-server/src/harnesses.ts b/packages/web-server/src/harnesses.ts index 89c4b3fe2..e8a9eca04 100644 --- a/packages/web-server/src/harnesses.ts +++ b/packages/web-server/src/harnesses.ts @@ -301,7 +301,7 @@ export class HarnessRegistry { } } -function groupOf( +export function groupOf( id: string, name: string, inspection: Extract, diff --git a/packages/web-server/src/main.ts b/packages/web-server/src/main.ts index b984673ec..8b1e8c43a 100644 --- a/packages/web-server/src/main.ts +++ b/packages/web-server/src/main.ts @@ -21,6 +21,8 @@ import { registerMisc } from "./misc.ts"; import { PushNotifier } from "./push.ts"; import { CLIENT_GLOBALS, CLIENT_PLUGINS } from "./plugins.ts"; import { Sessions } from "./sessions.ts"; +import { ChSessions } from "./ch-sessions.ts"; +import { DesktopChHostClient } from "./ch-host-client.ts"; import { Settings } from "./settings.ts"; import { DataDir } from "./store.ts"; import { EventHub, RpcRegistry, StreamRegistry } from "./transport.ts"; @@ -39,6 +41,8 @@ function defaultAdapterRoots(): string { const { values } = parseArgs({ options: { + "session-source": { type: "string", default: "codexhost" }, + "ch-cdp": { type: "string" }, port: { type: "string", default: process.env.CODEXHOST_WEB_PORT ?? "3180" }, host: { type: "string", default: process.env.CODEXHOST_WEB_HOST ?? "127.0.0.1" }, data: { @@ -74,8 +78,17 @@ const harnesses = new HarnessRegistry( (values.adapters as string).split(",").map((path) => resolve(path)), values.harness === undefined ? undefined : new Set(values.harness.split(",")), ); -const workspaces = new Workspaces(data, resolve(values.workspace as string)); -const sessions = new Sessions(data, harnesses, workspaces, events); +const workspaces = new Workspaces( + data, + resolve(values.workspace as string), + values["session-source"] === "codexhost", +); +if (!["codexhost", "standalone"].includes(values["session-source"] as string)) + throw new Error("Unknown --session-source"); +const sessions = + values["session-source"] === "standalone" + ? new Sessions(data, harnesses, workspaces, events) + : new ChSessions(new DesktopChHostClient(values["ch-cdp"]), workspaces, events); const settings = new Settings(data, events); const push = new PushNotifier(data); sessions.notifier = (notification) => { @@ -109,7 +122,7 @@ sessions.registerCommands(rpc); sessions.registerImport(rpc); settings.register(rpc); push.register(rpc); -registerMisc(rpc, { harnesses, sessions, events }); +registerMisc(rpc, { events }); function send(response: ServerResponse, asset: AssetResponse | undefined, cache = false): void { if (asset === undefined) { @@ -200,7 +213,12 @@ const server = createServer((request, response) => { return; } if (pathname.startsWith("/harness-icons/")) { - const icon = harnesses.iconPath(decodeURIComponent(pathname.slice("/harness-icons/".length))); + const id = decodeURIComponent(pathname.slice("/harness-icons/".length)); + if (sessions instanceof ChSessions) { + send(response, await sessions.catalog.icon(id)); + return; + } + const icon = harnesses.iconPath(id); send( response, icon === undefined || !existsSync(icon) diff --git a/packages/web-server/src/misc.ts b/packages/web-server/src/misc.ts index 98a137d81..fdfc8efdc 100644 --- a/packages/web-server/src/misc.ts +++ b/packages/web-server/src/misc.ts @@ -4,15 +4,10 @@ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; -import type { HarnessRegistry } from "./harnesses.ts"; -import type { Sessions } from "./sessions.ts"; import { requestOf } from "./store.ts"; import { type EventHub, RpcError, type RpcRegistry } from "./transport.ts"; -export function registerMisc( - rpc: RpcRegistry, - deps: { harnesses: HarnessRegistry; sessions: Sessions; events: EventHub }, -): void { +export function registerMisc(rpc: RpcRegistry, deps: { events: EventHub }): void { rpc.register("$events/result", (args) => deps.events.settle(requestOf(args))); rpc.register("credentials/describe", (args) => { const refs = (args.refs as string[] | undefined) ?? []; diff --git a/packages/web-server/src/preview-launch.ts b/packages/web-server/src/preview-launch.ts index f21e2d335..f4a14a93d 100644 --- a/packages/web-server/src/preview-launch.ts +++ b/packages/web-server/src/preview-launch.ts @@ -11,6 +11,8 @@ export interface PreviewOptions { data?: string; workspace?: string; harness?: string; + "session-source"?: string; + "ch-cdp"?: string; } /** @@ -46,6 +48,9 @@ export function previewLaunch( env, args: [ resolve(distribution, "server.mjs"), + "--session-source", + options["session-source"] ?? "codexhost", + ...(options["ch-cdp"] ? ["--ch-cdp", options["ch-cdp"]] : []), "--host", "127.0.0.1", "--port", diff --git a/packages/web-server/src/projector.ts b/packages/web-server/src/projector.ts index 69c632896..75df02e0d 100644 --- a/packages/web-server/src/projector.ts +++ b/packages/web-server/src/projector.ts @@ -371,6 +371,8 @@ function toolResultText( } export interface TurnProjectorOptions { + /** Stable rendering ordinal for a paged, in-memory CH history window. */ + turn?: number; /** Emit assistant-stream presentation frames (false while importing history). */ live: boolean; /** Running tool output (accumulated text) for live terminal cards; `undefined` once the tool settles. */ @@ -399,7 +401,8 @@ export class TurnProjector { private readonly log: SessionLog, private readonly options: TurnProjectorOptions, ) { - this.turn = log.events.filter((event) => event.type === "turn/start").length + 1; + this.turn = + options.turn ?? log.events.filter((event) => event.type === "turn/start").length + 1; } /** Open the Turn with the user's prompt. */ diff --git a/packages/web-server/src/session-log.ts b/packages/web-server/src/session-log.ts index acfebb803..53fdb1847 100644 --- a/packages/web-server/src/session-log.ts +++ b/packages/web-server/src/session-log.ts @@ -108,8 +108,13 @@ export class SessionLog { constructor( readonly header: SessionHeader, - private readonly data: DataDir, + private readonly data: Pick< + DataDir, + "readLines" | "readJson" | "writeLines" | "writeJson" | "appendLine" + >, private readonly onProjection: ProjectionListener, + private sequenceStart = 0, + private readonly hasEarlier: () => boolean = () => false, ) { this.events = data.readLines(this.file); this.projections = { @@ -158,7 +163,30 @@ export class SessionLog { } get lastSeq(): number { - return this.events.length - 1; + return this.sequenceStart + this.events.length - 1; + } + + get firstSeq(): number { + return this.sequenceStart; + } + + /** Prepend an already projected, completed page in a disposable journal only. + * Existing event/message identities and live cursors must never be renumbered. + */ + prepend(events: readonly WireEvent[]): void { + if (!events.length) return; + const start = this.sequenceStart - events.length; + if (start < 0) throw new Error("History rendering sequence range exhausted"); + const shift = start - (events[0]?.seq ?? 0); + const mapped = events.map((event) => ({ + ...event, + seq: event.seq + shift, + ...(event.sourceEventSeqs + ? { sourceEventSeqs: event.sourceEventSeqs.map((seq) => seq + shift) } + : {}), + })); + this.events.unshift(...mapped); + this.sequenceStart = start; } /** Append one durable event and deliver it to followers. */ @@ -170,7 +198,7 @@ export class SessionLog { ): WireEvent { const event: WireEvent = { type, - seq: this.events.length, + seq: this.sequenceStart + this.events.length, time: time ?? this.clock?.() ?? Date.now(), data, ...extra, @@ -273,7 +301,7 @@ export class SessionLog { records: this.events .slice(window.start, this.events.length) .map((event) => ({ type: "event", event })), - hasMore: window.start > 0, + hasMore: window.start > 0 || this.hasEarlier(), projections: this.projectionBaseline(), ...(request.assistantStream === true ? { @@ -338,12 +366,16 @@ export class SessionLog { ): { records: unknown[]; hasMore: boolean } { const end = Math.max( 0, - Math.min(beforeSeq ?? throughSeq + 1, throughSeq + 1, this.events.length), + Math.min( + (beforeSeq ?? throughSeq + 1) - this.sequenceStart, + throughSeq + 1 - this.sequenceStart, + this.events.length, + ), ); const { start } = this.window(end, request); return { records: this.events.slice(start, end).map((event) => ({ type: "event", event })), - hasMore: start > 0, + hasMore: start > 0 || this.hasEarlier(), }; } } diff --git a/packages/web-server/src/workspaces.ts b/packages/web-server/src/workspaces.ts index 908d889f3..1f966dc6a 100644 --- a/packages/web-server/src/workspaces.ts +++ b/packages/web-server/src/workspaces.ts @@ -15,6 +15,7 @@ export interface WorkspaceView { sessionIds: string[]; createdAt: string; updatedAt: string; + origin?: "web" | "codexhost"; } interface WorkspaceState { @@ -28,10 +29,12 @@ const FILE = "workspaces.json"; export class Workspaces { private state: WorkspaceState; private readonly followers = new Set(); + private referenceGroups: Map | undefined; constructor( private readonly data: DataDir, private readonly defaultDirectory: string, + private readonly referenceOnlyDefault = false, ) { this.state = data.readJson(FILE, { items: [], @@ -51,11 +54,59 @@ export class Workspaces { private touch(workspace: WorkspaceView): void { workspace.updatedAt = new Date().toISOString(); this.save(); - this.broadcast({ type: "upsert", workspace }); + const visible = this.list().find((item) => item.workspaceId === workspace.workspaceId); + if (visible) this.broadcast({ type: "upsert", workspace: visible }); } list(): readonly WorkspaceView[] { - return this.state.items; + const references = this.referenceGroups; + if (!references) return this.state.items; + const allowedRoots = new Set( + [...references.values()].filter((path): path is string => path !== undefined), + ); + return this.state.items + .filter((workspace) => { + // Legacy automatic rows from the previous build are hidden, not deleted. + const legacyReference = + workspace.origin === undefined && + workspace.sessionIds.length > 0 && + workspace.sessionIds.some((id) => references.has(id)) && + workspace.sessionIds.every((id) => references.has(id) || id.startsWith("draft-")); + return ( + workspace.origin === "web" || + !(workspace.origin === "codexhost" || legacyReference) || + allowedRoots.has(workspace.path) + ); + }) + .map((workspace) => ({ + ...workspace, + sessionIds: workspace.sessionIds.filter( + (id) => + !references.has(id) || + references.get(id) === workspace.path || + (workspace.origin === "web" && references.get(id) === undefined), + ), + })); + } + + /** Replace only the shared navigation view; never delete user records or any directories. */ + addDraftReference(sessionId: string, cwd: string): void { + this.setReferenceGroups(new Map([...(this.referenceGroups ?? []), [sessionId, resolve(cwd)]])); + } + + setReferenceGroups(groups: Map): void { + const before = this.list(); + this.referenceGroups = groups; + const after = this.list(); + const ids = new Set(after.map((workspace) => workspace.workspaceId)); + for (const workspace of before) + if (!ids.has(workspace.workspaceId)) + this.broadcast({ type: "remove", workspaceId: workspace.workspaceId }); + for (const workspace of after) { + const old = before.find((item) => item.workspaceId === workspace.workspaceId); + if (JSON.stringify(old) !== JSON.stringify(workspace)) + this.broadcast({ type: "upsert", workspace }); + } } get(workspaceId: string): WorkspaceView | undefined { @@ -67,25 +118,31 @@ export class Workspaces { } ownerOf(sessionId: string): WorkspaceView | undefined { - return this.state.items.find((item) => item.sessionIds.includes(sessionId)); + return this.list().find((item) => item.sessionIds.includes(sessionId)); } isArchived(sessionId: string): boolean { return this.state.archivedSessionIds.includes(sessionId); } - create(path: string): { workspace: WorkspaceView; created: boolean } { + create(path: string, referenceOnly = false): { workspace: WorkspaceView; created: boolean } { const expanded = path.startsWith("~") ? join(homedir(), path.slice(1)) : path; if (!isAbsolute(expanded)) throw new RpcError("workspace/invalid-path", `Workspace path must be absolute: ${path}`, { path, }); const absolute = resolve(expanded); - if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + if (!referenceOnly && (!existsSync(absolute) || !statSync(absolute).isDirectory())) { throw new RpcError("workspace/invalid-path", `Not a directory: ${absolute}`, { path }); } const existing = this.byPath(absolute); - if (existing !== undefined) return { workspace: existing, created: false }; + if (existing !== undefined) { + if (!referenceOnly && existing.origin !== "web") { + existing.origin = "web"; + this.touch(existing); + } + return { workspace: existing, created: false }; + } const now = new Date().toISOString(); const workspace: WorkspaceView = { workspaceId: randomUUID(), @@ -94,6 +151,7 @@ export class Workspaces { sessionIds: [], createdAt: now, updatedAt: now, + origin: referenceOnly ? "codexhost" : "web", }; this.state.items.push(workspace); this.save(); @@ -125,6 +183,29 @@ export class Workspaces { return workspace; } + /** Mirror an authoritative CH cwd as a Web navigation entry, never mkdir or change the Thread. */ + attachReferencedSession(sessionId: string, cwd: string, title?: string): WorkspaceView { + const workspace = this.byPath(resolve(cwd)) ?? this.create(cwd, true).workspace; + if (title && workspace.origin !== "web" && workspace.title !== title) { + workspace.title = title; + this.touch(workspace); + } + if (!workspace.sessionIds.includes(sessionId)) { + workspace.sessionIds.push(sessionId); + this.touch(workspace); + } + return workspace; + } + + /** An actual Web submission makes its selected folder an explicit Web workspace. */ + selectReferenceWorkspace(cwd: string): void { + const workspace = this.byPath(resolve(cwd)) ?? this.create(cwd, true).workspace; + if (workspace.origin !== "web") { + workspace.origin = "web"; + this.touch(workspace); + } + } + detachSession(sessionId: string): void { for (const workspace of this.state.items) { const index = workspace.sessionIds.indexOf(sessionId); @@ -139,8 +220,12 @@ export class Workspaces { rpc.register("workspace/create", (args) => this.create(requestOf<{ path: string }>(args).path)); rpc.register("workspace/initializeDefault", () => { if (this.state.items.length > 0) return undefined; - mkdirSync(this.defaultDirectory, { recursive: true }); - return { workspace: this.create(this.defaultDirectory).workspace }; + if (!this.referenceOnlyDefault) mkdirSync(this.defaultDirectory, { recursive: true }); + return { + workspace: + (this.referenceOnlyDefault ? this.list()[0] : undefined) ?? + this.create(this.defaultDirectory, this.referenceOnlyDefault).workspace, + }; }); rpc.register("workspace/rename", (args) => { const request = requestOf<{ workspaceId: string; title: string }>(args); @@ -220,7 +305,7 @@ export class Workspaces { streams.register("workspace/follow", (_args, sink) => { this.followers.add(sink); sink.onClose(() => this.followers.delete(sink)); - sink.push({ type: "baseline", value: this.state }); + sink.push({ type: "baseline", value: { ...this.state, items: this.list() } }); }); } diff --git a/packages/web-server/test/browser/ch-history.test.ts b/packages/web-server/test/browser/ch-history.test.ts new file mode 100644 index 000000000..e40bd2168 --- /dev/null +++ b/packages/web-server/test/browser/ch-history.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { it } from "node:test"; +import { chromium } from "playwright"; +import { FakeChHost, startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; + +it( + "shows the native tail before slow older history and prepends without moving visible anchors", + { timeout: 60_000 }, + async (t) => { + const host = new FakeChHost(); + const row = host.add("long", "/computer/project", "Long paged history"); + row.turns = Array.from({ length: 20 }, (_, i) => ({ + id: `turn-${i}`, + status: "completed", + items: [ + { id: `u-${i}`, type: "userMessage", content: [{ type: "text", text: `Question ${i}` }] }, + { + id: `a-${i}`, + type: "agentMessage", + text: + `Answer ${i}: ` + + "A historical answer with enough lines to verify stable scroll anchoring. ".repeat(15), + }, + ], + })); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + t.after(() => release()); + const request = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "thread/turns/list" && params.cursor) await gate; + return request(method, params); + }; + const debug = await startFakeChDebugger(host); + t.after(() => debug.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + ["--session-source", "codexhost", "--ch-cdp", debug.endpoint], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + const folder = page.getByRole("treeitem").filter({ hasText: /^project$/ }); + await folder.waitFor(); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + await page.getByRole("treeitem").filter({ hasText: "Long paged history" }).click(); + await page.getByText("Question 19", { exact: true }).waitFor(); + assert.equal(await page.getByText("Question 14", { exact: true }).count(), 0); + const head = host.requests.filter((r) => r.method === "thread/turns/list"); + assert.equal(head.length, 1, "tail is visible while older native reads remain blocked"); + assert.equal(head[0]?.params.limit, 5); + const screenshots = process.env.CODEXHOST_TEST_SCREENSHOTS; + if (screenshots) { + mkdirSync(screenshots, { recursive: true }); + await page.screenshot({ path: join(screenshots, "paged-tail-before-older.png") }); + } + release(); + const earlier = page.getByRole("button", { name: "Load earlier", exact: true }); + await earlier.scrollIntoViewIfNeeded(); + const anchor = page.getByText("Question 15", { exact: true }); + await anchor.waitFor(); + const before = await anchor.boundingBox(); + assert.ok(before); + const key = await anchor + .locator("xpath=ancestor::*[@data-chat-node-key][1]") + .getAttribute("data-chat-node-key"); + await earlier.click(); + await page.getByText("Question 10", { exact: true }).waitFor(); + // Layout settlement only; the assertion measures the original visible message, + // not a new synthetic offset or an injected scroll position. + await page.waitForTimeout(500); + const after = await anchor.boundingBox(); + assert.ok(after); + assert.ok(Math.abs(after.y - before.y) < 4, `anchor moved ${after.y - before.y}px`); + assert.equal( + await anchor + .locator("xpath=ancestor::*[@data-chat-node-key][1]") + .getAttribute("data-chat-node-key"), + key, + ); + if (screenshots) + await page.screenshot({ path: join(screenshots, "paged-anchor-after-prepend.png") }); + await earlier.click(); + await page.getByText("Question 5", { exact: true }).waitFor(); + await earlier.click(); + await page.getByText("Question 0", { exact: true }).waitFor(); + assert.equal(await earlier.count(), 0); + assert.equal(host.requests.filter((r) => r.method === "thread/turns/list").length, 4); + assert.deepEqual(errors, []); + await page.reload(); + await page.getByText("Question 19", { exact: true }).waitFor(); + assert.equal( + host.requests.filter((r) => r.method === "thread/turns/list").length, + 4, + "reopening reuses the loaded native window", + ); + }, +); diff --git a/packages/web-server/test/browser/ch-sessions.test.ts b/packages/web-server/test/browser/ch-sessions.test.ts new file mode 100644 index 000000000..435690256 --- /dev/null +++ b/packages/web-server/test/browser/ch-sessions.test.ts @@ -0,0 +1,156 @@ +/** Two clients naturally see one CH Thread store; no real GUI/model/CLI is touched. */ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { it } from "node:test"; +import { chromium } from "playwright"; +import { FakeChHost, startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; + +for (const width of [390, 1280]) + it( + `CH threads and automatic working-directory groups at ${width}px`, + { timeout: 60_000 }, + async (t) => { + const host = new FakeChHost(); + host.add("gui-existing", "/computer/main-project", "From Codex GUI"); + host.add("gui-worktree", "/computer/worktrees/task-a", "From another worktree"); + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + ["--session-source", "codexhost", "--ch-cdp", debuggerServer.endpoint], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + isMobile: width < 600, + hasTouch: width < 600, + }); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(url); + const open = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (await open.first().isVisible()) await open.first().click(); + const projectFolder = page.getByRole("treeitem").filter({ hasText: /^main-project$/ }); + await projectFolder.waitFor(); + if ((await projectFolder.getAttribute("aria-expanded")) === "false") + await projectFolder.click(); + await page + .getByRole("treeitem") + .filter({ hasText: "From Codex GUI" }) + .waitFor() + .catch(async (error: unknown) => { + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `ch-opening-failure-${width}.png`), + }); + } + throw error; + }); + if (width < 600 && (await open.last().isVisible())) await open.last().click(); + await page + .getByRole("treeitem") + .filter({ hasText: /^task-a$/ }) + .click(); + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join( + process.env.CODEXHOST_TEST_SCREENSHOTS, + `ch-directory-observation-${width}.png`, + ), + animations: "disabled", + }); + } + await page.getByRole("treeitem").filter({ hasText: "From another worktree" }).waitFor(); + const row = page.getByRole("treeitem").filter({ hasText: "From Codex GUI" }); + assert.equal(await row.getAttribute("data-row-key"), "session:gui-existing"); + await row.click(); + await page.getByText("existing CH answer", { exact: true }).waitFor(); + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `ch-history-${width}.png`), + animations: "disabled", + }); + } + const sidebar = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (width < 600) { + await page + .getByRole("button", { name: "New session", exact: true }) + .filter({ hasText: "New Session" }) + .waitFor({ state: "hidden" }); + await sidebar.last().click(); + } + if (process.env.CODEXHOST_TEST_SCREENSHOTS) + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `ch-directories-${width}.png`), + animations: "disabled", + }); + await page + .getByRole("button", { name: "New session", exact: true }) + .filter({ hasText: "New Session" }) + .click(); + await page + .getByRole("textbox", { + name: "Describe what you want to build, / commands, @ files or sessions", + exact: true, + }) + .fill("created in Web"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await page.getByText("CH response: created in Web", { exact: true }).waitFor(); + assert.equal(host.requests.filter((r) => r.method === "thread/start").length, 1); + const newThread = [...host.threads.values()].find((value) => + value.id.startsWith("canonical-"), + ); + assert.ok(newThread); + assert.ok( + host.requests.some((r) => r.method === "thread/read" && r.params.threadId === newThread.id), + ); + await page.reload(); + await page.getByText("CH response: created in Web", { exact: true }).waitFor(); + if (process.env.CODEXHOST_TEST_SCREENSHOTS) + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `ch-canonical-restored-${width}.png`), + animations: "disabled", + }); + assert.deepEqual(errors, []); + assert.equal( + host.requests.some((r) => r.method.includes("project/")), + false, + ); + }, + ); + +it( + "shows a native history failure instead of leaving the loading hint indefinitely", + { timeout: 30_000 }, + async (t) => { + const host = new FakeChHost(); + host.add("unreadable", "/computer/project", "Unreadable history"); + host.historyError = "Native history unavailable"; + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + ["--session-source", "codexhost", "--ch-cdp", debuggerServer.endpoint], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.goto(url); + const folder = page.getByRole("treeitem").filter({ hasText: /^project$/ }); + await folder.waitFor(); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + await page.getByRole("treeitem").filter({ hasText: "Unreadable history" }).click(); + await page.getByText(/Failed to load history:.*Native history unavailable/).waitFor(); + assert.equal(await page.getByText("Loading history…", { exact: true }).count(), 0); + }, +); diff --git a/packages/web-server/test/browser/native-presentation.test.ts b/packages/web-server/test/browser/native-presentation.test.ts index cfac12683..859863e8f 100644 --- a/packages/web-server/test/browser/native-presentation.test.ts +++ b/packages/web-server/test/browser/native-presentation.test.ts @@ -17,7 +17,7 @@ async function shot(page: Page, name: string) { }); } async function sidebar(page: Page) { - const open = page.getByRole("button", { name: "Open sidebar", exact: true }); + const open = page.getByRole("button", { name: "Open sidebar", exact: true }).last(); if (await open.isVisible()) await open.click(); } for (const width of [390, 1280]) diff --git a/packages/web-server/test/ch-history-concurrency.test.ts b/packages/web-server/test/ch-history-concurrency.test.ts new file mode 100644 index 000000000..a2834d86a --- /dev/null +++ b/packages/web-server/test/ch-history-concurrency.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { ChThreadHistory } from "../src/ch-thread-history.ts"; +import { FakeChHost } from "./support/ch-host.ts"; + +it("a slow background older page does not block new tail output", async (t) => { + const host = new FakeChHost(); + const row = host.add("history", "/project"); + const initial = row.turns[0]; + assert.ok(initial); + row.turns = Array.from({ length: 12 }, (_, i) => ({ ...initial, id: `turn-${i}` })); + const view = new ChThreadHistory(host, row, "fake", () => {}); + await view.refresh(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.after(() => release()); + const request = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "thread/turns/list" && params.cursor) await gate; + return request(method, params); + }; + const older = view.prefetchOlder(); + row.turns.push({ ...initial, id: "new-turn" }); + row.updatedAt++; + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + view.refresh(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Tail blocked by background history")), 500); + }), + ]); + assert.equal(view.thread.turns.at(-1)?.id, "new-turn"); + } finally { + clearTimeout(timeout); + release(); + await older; + } + assert.equal(view.thread.turns[0]?.id, "turn-2"); + const count = host.requests.length; + await view.prefetchOlder(); + assert.equal(host.requests.length, count, "reopening does not drain another older page"); +}); diff --git a/packages/web-server/test/ch-sessions.test.ts b/packages/web-server/test/ch-sessions.test.ts new file mode 100644 index 000000000..4fb62dfda --- /dev/null +++ b/packages/web-server/test/ch-sessions.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readdirSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it } from "node:test"; +import { DataDir } from "../src/store.ts"; +import { Workspaces } from "../src/workspaces.ts"; +import { EventHub, RpcRegistry, StreamRegistry } from "../src/transport.ts"; +import { ChSessions } from "../src/ch-sessions.ts"; +import { DesktopChHostClient } from "../src/ch-host-client.ts"; +import { projectGroupRoot } from "../src/ch-project-groups.ts"; +import { FakeChHost, startFakeChDebugger } from "./support/ch-host.ts"; + +it("shares canonical CH identity and cwd without a Web index/history or physical mkdir", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-web-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChHost(); + host.add("gui-thread", join(root, "deleted-worktree")); + const data = new DataDir(root), + events = new EventHub(root), + workspaces = new Workspaces(data, root), + sessions = new ChSessions(host, workspaces, events), + rpc = new RpcRegistry(); + t.after(() => sessions.close()); + sessions.register(rpc, new StreamRegistry()); + sessions.registerCommands(rpc); + const call = async (method: string, args: object = {}): Promise => { + const result = await rpc.dispatch(method, { args }); + assert.ok(result.ok, result.ok ? undefined : result.error.message); + return result.value as T; + }; + const list = await call<{ items: Array<{ sessionId: string; cwd: string }> }>("session/list"); + assert.equal(list.items[0]?.sessionId, "gui-thread"); + assert.equal(workspaces.ownerOf("gui-thread")?.path, join(root, "deleted-worktree")); + assert.equal(existsSync(join(root, "deleted-worktree")), false); + await call("session/projections", { request: { sessionId: "gui-thread" } }); + const canonical = host.threads.get("gui-thread"); + assert.ok(canonical); + assert.equal(canonical.cwd, join(root, "deleted-worktree")); + assert.deepEqual(readdirSync(root), ["workspaces.json"]); + const draft = await call<{ sessionId: string }>("session/create", { request: { cwd: root } }); + assert.equal( + host.requests.some((req) => req.method === "thread/start"), + false, + "creating browser draft must not allocate native history", + ); + await call("session/prompt", { + request: { + sessionId: draft.sessionId, + requestId: "send-1", + content: [{ type: "text", text: "hello from Web" }], + mode: "queue", + }, + }); + const created = [...host.threads.values()].find((row) => row.id !== "gui-thread"); + assert.ok(created); + assert.equal(created.cwd, root); + assert.equal(created.turns.length, 1); + const start = host.requests.find((req) => req.method === "thread/start"); + assert.ok(start); + assert.equal("projectId" in start.params, false); + assert.equal(host.requests.filter((req) => req.method === "turn/start").length, 1); + const again = await call<{ items: Array<{ sessionId: string }> }>("session/list"); + assert.ok(again.items.some((row) => row.sessionId === created.id)); + assert.ok(!again.items.some((row) => row.sessionId === draft.sessionId)); + assert.deepEqual(readdirSync(root), ["workspaces.json"]); +}); + +it("shares one native history read across concurrent browser consumers", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-read-sharing-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChHost(); + host.add("history", root); + const sessions = new ChSessions( + host, + new Workspaces(new DataDir(root), root), + new EventHub(root), + ); + t.after(() => sessions.close()); + const rpc = new RpcRegistry(); + sessions.register(rpc, new StreamRegistry()); + await rpc.dispatch("session/list", { args: {} }); + const replies = await Promise.all( + [1, 2, 3].map(() => + rpc.dispatch("session/projections", { args: { request: { sessionId: "history" } } }), + ), + ); + assert.ok(replies.every((reply) => reply.ok)); + assert.equal(host.requests.filter((request) => request.method === "thread/turns/list").length, 1); +}); + +it("reuses idle history and does not wait for Harness configuration", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-fast-history-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChHost(); + host.add("history", root); + const request = host.request.bind(host); + let release!: () => void; + const configuration = new Promise((resolve) => { + release = resolve; + }); + t.after(() => release()); + host.request = async (method: string, params: Record): Promise => { + if (method === "codexhost/harness/inspect") await configuration; + return request(method, params); + }; + const sessions = new ChSessions( + host, + new Workspaces(new DataDir(root), root), + new EventHub(root), + ); + t.after(() => sessions.close()); + const rpc = new RpcRegistry(); + sessions.register(rpc, new StreamRegistry()); + await rpc.dispatch("session/list", { args: {} }); + const read = () => + rpc.dispatch("session/projections", { args: { request: { sessionId: "history" } } }); + let timeout: NodeJS.Timeout | undefined; + const result = await Promise.race([ + read(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("History is blocked by Harness configuration")), + 500, + ); + }), + ]).finally(() => clearTimeout(timeout)); + assert.ok(result.ok); + release(); + assert.ok((await read()).ok); + assert.equal(host.requests.filter((r) => r.method === "thread/turns/list").length, 1); + const row = host.threads.get("history"); + assert.ok(row); + row.updatedAt += 1; + await rpc.dispatch("session/list", { args: {} }); + assert.ok((await read()).ok); + assert.equal(host.requests.filter((r) => r.method === "thread/turns/list").length, 2); +}); + +it("does not create project groups from auto-assigned cwd alone", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-cwd-filter-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChHost(); + host.add("auto-thread", join(root, "generated-unselected-directory"), "Unselected", false); + const workspaces = new Workspaces(new DataDir(root), root); + const original = workspaces.attachReferencedSession( + "auto-thread", + join(root, "generated-unselected-directory"), + ); + delete original.origin; + const sessions = new ChSessions(host, workspaces, new EventHub(root)); + t.after(() => sessions.close()); + const rpc = new RpcRegistry(); + sessions.register(rpc, new StreamRegistry()); + const result = await rpc.dispatch("session/list", { args: {} }); + assert.ok(result.ok); + assert.ok(workspaces.get(original.workspaceId), "Existing navigation data is not deleted"); + assert.ok(!workspaces.list().some((folder) => folder.workspaceId === original.workspaceId)); + assert.equal( + workspaces.ownerOf("auto-thread"), + undefined, + "An execution cwd is not evidence of a selected project", + ); +}); + +it("groups assigned worktrees under their GUI project without changing execution cwd", () => { + const snapshot = { + projects: [{ id: "p", name: "Selected", rootPaths: ["/computer/project"] }], + assignments: { assigned: "p" }, + projectless: ["unselected"], + }; + assert.equal( + projectGroupRoot(snapshot, "assigned", "/computer/worktrees/isolated"), + "/computer/project", + ); + assert.equal( + projectGroupRoot(snapshot, "nested", "/computer/project/subdirectory"), + "/computer/project", + ); + assert.equal(projectGroupRoot(snapshot, "unselected", "/computer/project/generated"), undefined); + assert.equal(projectGroupRoot(snapshot, "unknown", "/computer/project-other"), undefined); +}); + +it("uses existing Desktop routing without installing code, and does not retry rejected writes", async (t) => { + const host = new FakeChHost(); + host.add("thread", "/workspace"); + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + const client = new DesktopChHostClient(debuggerServer.endpoint); + t.after(() => client.close()); + const list = await client.request<{ data: Array<{ id: string }> }>("thread/list", {}); + assert.equal(list.data[0]?.id, "thread"); + assert.deepEqual(await client.projects(["thread"]), host.projectSnapshot); + await assert.rejects( + client.request("turn/start", { threadId: "missing", input: [] }), + /Missing CH Thread/, + ); + assert.equal(host.requests.filter((req) => req.method === "turn/start").length, 1); + await assert.rejects(client.request("shell/execute", {}), /Unsupported CH method/); +}); diff --git a/packages/web-server/test/ch-thread-history.test.ts b/packages/web-server/test/ch-thread-history.test.ts new file mode 100644 index 000000000..8968fef96 --- /dev/null +++ b/packages/web-server/test/ch-thread-history.test.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { ChThreadHistory } from "../src/ch-thread-history.ts"; +import { FakeChHost } from "./support/ch-host.ts"; +import type { ChTurn } from "../src/ch-thread-view.ts"; + +function turns(count: number, start = 0): ChTurn[] { + return Array.from({ length: count }, (_, index) => { + const i = index + start; + return { + id: `turn-${i}`, + status: "completed", + items: [ + { + id: `user-${i}`, + type: "userMessage", + content: [{ type: "text", text: `Question ${i}` }], + }, + { id: `reasoning-${i}`, type: "reasoning", summary: ["Thought"], content: [] }, + { + id: `tool-${i}`, + type: "commandExecution", + command: "pwd", + aggregatedOutput: "/project", + exitCode: 0, + }, + { id: `agent-${i}`, type: "agentMessage", text: `Answer ${i}` }, + ], + }; + }); +} +function fixture(count = 205) { + const host = new FakeChHost(); + const row = host.add("history", "/project"); + row.turns = turns(count); + const view = new ChThreadHistory(host, row, "fake", () => {}); + return { host, row, view }; +} + +it("opens only the latest five full Turns, then pages backwards without renumbering the tail", async () => { + const { host, row, view } = fixture(); + await view.refresh(); + assert.deepEqual(view.thread.turns, row.turns.slice(-5)); + assert.equal(host.requests.filter((r) => r.method === "thread/turns/list").length, 1); + assert.deepEqual(host.requests[1]?.params, { + threadId: row.id, + cursor: null, + limit: 5, + sortDirection: "desc", + itemsView: "full", + }); + const tail = structuredClone(view.log.events); + const cursor = view.log.lastSeq; + let beforeSeq = view.log.firstSeq; + let pages = 0; + while (true) { + const page = await view.olderPage(cursor, beforeSeq); + pages++; + const first = (page.records[0] as { event: { seq: number } })?.event.seq; + assert.ok(first < beforeSeq); + beforeSeq = first; + if (!page.hasMore) break; + assert.ok(pages < 50); + } + assert.equal(pages, 40); + assert.deepEqual(view.thread.turns, row.turns); + assert.deepEqual(view.log.events.slice(-tail.length), tail); + assert.equal(view.log.lastSeq, cursor); + assert.equal(new Set(view.log.events.map((e) => e.seq)).size, view.log.events.length); + for (let i = 1; i < view.log.events.length; i++) + assert.equal(view.log.events[i]?.seq, (view.log.events[i - 1]?.seq ?? 0) + 1); + for (const event of view.log.events) + for (const source of event.sourceEventSeqs ?? []) + assert.ok( + view.log.events.some((e) => e.seq === source), + "tool references survive prepend", + ); + assert.ok(host.requests.every((r) => ["thread/read", "thread/turns/list"].includes(r.method))); +}); + +it("does not drain history on refresh and fills new-Turn gaps beyond five Turns", async () => { + const { host, row, view } = fixture(20); + await view.refresh(); + await view.refresh(); + assert.equal(host.requests.filter((r) => r.method === "thread/turns/list").length, 1); + const tail = structuredClone(view.log.events); + row.turns.push(...turns(12, 20)); + row.updatedAt++; + await view.refresh(); + assert.deepEqual(view.thread.turns, row.turns.slice(15)); + assert.deepEqual(view.log.events.slice(0, tail.length), tail); + await view.loadOlder(); + assert.deepEqual(view.thread.turns, row.turns.slice(10)); +}); + +it("coalesces older pages and retries errors without advancing the cursor", async () => { + const { host, view } = fixture(12); + await view.refresh(); + host.historyError = "temporary history failure"; + const boundary = view.log.firstSeq; + await assert.rejects(view.loadOlder(), /temporary history failure/); + assert.equal(view.log.firstSeq, boundary); + host.historyError = undefined; + const before = host.requests.length; + await Promise.all([view.loadOlder(), view.loadOlder(), view.loadOlder()]); + assert.equal(host.requests.length - before, 1); + assert.equal(view.thread.turns.length, 10); +}); + +it("keeps one Turn and message identity while active output becomes complete", async () => { + const { row, view } = fixture(6); + const last = row.turns.at(-1); + assert.ok(last); + last.status = "inProgress"; + last.items = [{ id: "a", type: "agentMessage", text: "hello" }]; + row.status.type = "active"; + await view.refresh(); + await view.loadOlder(); + const item = last.items[0]; + assert.ok(item); + item.text = "hello world"; + last.status = "completed"; + row.status.type = "idle"; + await view.refresh(); + const starts = view.log.events.filter((e) => e.type === "turn/start"); + assert.equal(starts.length, 6); + assert.equal(new Set(starts.map((e) => (e.data as { turn: number }).turn)).size, 6); + assert.ok(JSON.stringify(view.log.events).includes("hello world")); +}); + +it("handles empty history and new Turns without rereading an unavailable older prefix", async () => { + const { row, view } = fixture(0); + await view.refresh(); + assert.equal(view.log.events.length, 0); + row.turns = turns(2); + row.updatedAt++; + await view.refresh(); + assert.equal(view.thread.turns.length, 2); + assert.equal((await view.olderPage(view.log.lastSeq, view.log.firstSeq)).hasMore, false); +}); + +it("rejects repeated cursors and rewritten history rather than looping or fabricating continuity", async () => { + const { host, row, view } = fixture(12); + await view.refresh(); + const request = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "thread/turns/list" && params.cursor) + return { data: [], nextCursor: params.cursor } as T; + return request(method, params); + }; + await assert.rejects(view.loadOlder(), /cursor did not advance/); + host.request = request; + row.turns = turns(3); + row.updatedAt++; + await assert.rejects(view.refresh(), /history was rewritten/); +}); diff --git a/packages/web-server/test/support/ch-host.ts b/packages/web-server/test/support/ch-host.ts new file mode 100644 index 000000000..5644fef1a --- /dev/null +++ b/packages/web-server/test/support/ch-host.ts @@ -0,0 +1,284 @@ +/** Real CH wire-shape fixture, not an independent Harness implementation. */ +import { createServer } from "node:http"; +import { basename } from "node:path"; +import { runInNewContext } from "node:vm"; +import { WebSocketServer } from "ws"; +import type { ChHostClient, ChProjectSnapshot } from "../../src/ch-host-client.ts"; +import type { ChThread } from "../../src/ch-thread-view.ts"; +import { decodeHarnessPluginRoute } from "@codexhost/shared-contracts"; + +export class FakeChHost implements ChHostClient { + readonly requests: Array<{ method: string; params: Record }> = []; + readonly threads = new Map(); + readonly modes = new Map(); + readonly projectSnapshot: ChProjectSnapshot = { projects: [], assignments: {}, projectless: [] }; + async projects(): Promise { + return structuredClone(this.projectSnapshot); + } + closed = false; + historyError: string | undefined; + readonly inspection = { + status: "ready", + catalog: { + models: [{ ref: { id: "native" }, label: "Native Model" }], + defaultModel: { id: "native" }, + thinkingOptions: [], + }, + permissionModes: { + modes: [ + { id: "ask", label: "Ask" }, + { id: "auto", label: "Native Auto" }, + ], + defaultModeId: "ask", + }, + capabilities: { + configuration: { selectModel: true, selectThinkingOption: false, selectPermissionMode: true }, + history: { fork: false, forkAcrossCwd: false, rollbackLastTurn: false }, + }, + }; + add(id: string, cwd: string, title = id, selectedProject = true): ChThread { + if (selectedProject) { + this.projectSnapshot.projects.push({ + id: "project-" + id, + name: basename(cwd), + rootPaths: [cwd], + }); + this.projectSnapshot.assignments[id] = "project-" + id; + } else this.projectSnapshot.projectless.push(id); + const row: ChThread = { + id, + cwd, + modelProvider: "codexhost", + name: title, + createdAt: 1000, + updatedAt: 2000, + status: { type: "idle" }, + turns: [ + { + id: "history-" + id, + status: "completed", + items: [ + { + id: "u-" + id, + type: "userMessage", + content: [{ type: "text", text: "earlier question" }], + }, + { id: "a-" + id, type: "agentMessage", text: "existing CH answer" }, + ], + }, + ], + }; + this.threads.set(id, row); + return row; + } + async request(method: string, params: Record): Promise { + if (this.closed) throw new Error("Host offline"); + this.requests.push({ method, params }); + let value: unknown; + const row = this.threads.get(String(params.threadId)); + switch (method) { + case "thread/list": + value = { data: params.archived ? [] : [...this.threads.values()], nextCursor: null }; + break; + case "codexhost/harness/plugins/list": + value = { + plugins: [ + { + id: "fake", + name: "CH Harness", + version: "1.0.0", + icon: + "data:image/svg+xml;base64," + + Buffer.from( + '', + ).toString("base64"), + }, + ], + }; + break; + case "codexhost/harness/inspect": + value = this.inspection; + break; + case "codexhost/thread/ownership/list": + value = { + threads: (params.threadIds as string[]).map((id) => ({ + threadId: id, + owner: "external", + harnessId: "fake", + })), + }; + break; + case "codexhost/thread/inspect": + if (!row) throw new Error("Missing CH Thread"); + value = { + owner: "external", + harnessId: "fake", + transportModelId: "codexhost/plugin", + effectiveModel: { id: "native" }, + effectivePermissionModeId: this.modes.get(row.id) ?? "ask", + locked: true, + history: this.inspection.capabilities.history, + }; + break; + case "thread/read": + if (!row) throw new Error("Missing CH Thread"); + if (params.includeTurns === true) + throw new Error("Paginated External Threads require thread/turns/list"); + value = { thread: { ...structuredClone(row), turns: [] } }; + break; + case "thread/turns/list": { + if (this.historyError) throw new Error(this.historyError); + if (!row) throw new Error("Missing CH Thread"); + if (params.itemsView !== "full") throw new Error("Full history items must be requested"); + const ordered = params.sortDirection === "asc" ? row.turns : [...row.turns].reverse(); + const anchor = params.cursor ? String(params.cursor) : undefined; + const index = anchor ? ordered.findIndex((turn) => turn.id === anchor) + 1 : 0; + const page = ordered.slice(index, index + Number(params.limit ?? 25)); + value = { + data: structuredClone(page), + nextCursor: index + page.length < ordered.length ? page.at(-1)?.id : null, + backwardsCursor: page[0]?.id ?? null, + }; + break; + } + case "thread/start": { + const route = decodeHarnessPluginRoute(params.model); + if (route?.harnessId !== "fake") throw new Error("Wrong Harness route"); + const created = this.add( + "canonical-" + (this.threads.size + 1), + String(params.cwd), + "Web-created CH Thread", + false, + ); + created.turns = []; + value = { thread: structuredClone(created) }; + break; + } + case "turn/start": { + if (!row) throw new Error("Missing CH Thread"); + const id = "turn-" + (row.turns.length + 1); + const input = params.input as Array<{ text: string }>; + row.turns.push({ + id, + status: "completed", + items: [ + { id: id + "-user", type: "userMessage", content: input }, + { + id: id + "-agent", + type: "agentMessage", + text: "CH response: " + input.map((p) => p.text).join(""), + }, + ], + }); + value = { turn: { id, status: "completed" } }; + break; + } + case "codexhost/thread/permission-mode/select": + this.modes.set(String(params.threadId), String(params.permissionModeId)); + value = { effectivePermissionModeId: params.permissionModeId }; + break; + case "codexhost/thread/model/select": + value = { effectiveModel: params.model }; + break; + case "thread/name/set": + if (row) row.name = String(params.name); + value = {}; + break; + case "turn/interrupt": + value = {}; + break; + default: + throw new Error("Unhandled CH fixture method: " + method); + } + return value as T; + } + close() { + this.closed = true; + } +} + +/** Browser acceptance reaches the production Desktop transport through an isolated fake CDP peer. */ +export async function startFakeChDebugger(host: FakeChHost) { + const server = createServer((_req, res) => { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify([ + { + id: "fixture", + type: "page", + title: "Fixture", + url: "app://codex/index.html", + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/debug`, + }, + ]), + ); + }); + const sockets = new WebSocketServer({ server }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No debugger address"); + const port = address.port; + sockets.on("connection", (socket) => + socket.on("message", (raw) => { + const frame = JSON.parse(String(raw)) as { + id: number; + method: string; + params: { expression: string }; + }; + if (frame.method !== "Runtime.evaluate") { + socket.send(JSON.stringify({ id: frame.id, result: {} })); + return; + } + const window = { + __codexhostRendererBindingProbeV1: {}, + __codexhostHostRoutingV1: { + knownHostIds: () => ["local"], + forHost: () => ({ + manager: { + threadWorkspaceStorage: { + storage: { + readGlobalState: async (key: string) => { + if (key === "local-projects") + return Object.fromEntries( + host.projectSnapshot.projects.map((project) => [project.id, project]), + ); + if (key === "project-order") + return host.projectSnapshot.projects.map((project) => project.id); + if (key === "projectless-thread-ids") return host.projectSnapshot.projectless; + if (key === "thread-project-assignments") + return Object.fromEntries( + Object.entries(host.projectSnapshot.assignments).map(([id, projectId]) => [ + id, + { projectKind: "local", projectId }, + ]), + ); + throw new Error("Unknown native project key"); + }, + }, + }, + sendRequest: (method: string, params: Record) => + host.request(method, params), + }, + }), + }, + }; + void Promise.resolve(runInNewContext(frame.params.expression, { window })).then( + (value) => + socket.send( + JSON.stringify({ id: frame.id, result: { result: { type: "object", value } } }), + ), + (error) => + socket.send( + JSON.stringify({ id: frame.id, result: { exceptionDetails: { text: String(error) } } }), + ), + ); + }), + ); + return { + endpoint: `http://127.0.0.1:${port}`, + close: async () => { + for (const socket of sockets.clients) socket.close(); + await new Promise((resolve) => sockets.close(() => server.close(() => resolve()))); + }, + }; +} diff --git a/packages/web-server/test/support/server.ts b/packages/web-server/test/support/server.ts index 44f17dcbe..f116aeac8 100644 --- a/packages/web-server/test/support/server.ts +++ b/packages/web-server/test/support/server.ts @@ -34,6 +34,7 @@ export async function startServer( "--workspace", join(data, "workspace"), "--no-auth", + ...(!args.includes("--session-source") ? ["--session-source", "standalone"] : []), ...args, ], { diff --git a/packages/web-server/tsconfig.json b/packages/web-server/tsconfig.json index 362a4c2ae..96cd6021d 100644 --- a/packages/web-server/tsconfig.json +++ b/packages/web-server/tsconfig.json @@ -11,6 +11,7 @@ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [ + { "path": "../desktop-control/tsconfig.json" }, { "path": "../harness-adapter/tsconfig.json" }, { "path": "../harness-plugin-files/tsconfig.json" }, { "path": "../shared-contracts/tsconfig.json" } From bd6604c5ffabf3a87e0bd28bba13bc8e50216f4a Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 10 Oct 2026 15:08:59 +0800 Subject: [PATCH 004/106] fix: improve web session switching and history navigation Publish only changed CH catalog entries to prevent browser update storms. Load older history on upward scrolling with stable anchors and explicit failure retries. Move the turn navigator into the left gutter with graduated markers, right-hand previews, pointer scrubbing, keyboard navigation and responsive visibility. Add catalog, switching, automatic paging and navigation regressions. Validation: 50 unit, 15 browser and 2 distribution tests; build, typecheck, lint and formatting. One question-answer test timed out initially; focused and full unit reruns passed. --- .../src/client/contract/session.ts | 2 +- .../src/client/contract/snapshot.ts | 2 + .../src/client/sessions/session.ts | 20 ++- .../src/client/chat/ChatView.module.css | 6 + .../ui-chat/src/client/chat/ChatView.tsx | 19 +- .../src/client/chat/TurnNavigator.module.css | 102 ++++++----- .../ui-chat/src/client/chat/TurnNavigator.tsx | 156 +++++++++++++++-- .../src/client/chat/use-chat-navigation.ts | 16 ++ .../src/client/chat/use-chat-scroll.ts | 19 +- .../src/client/chat/use-chat-viewport.ts | 85 +++++++-- .../client/ui-chat/src/client/locale.ts | 20 ++- docs/product/standalone-web.md | 10 +- packages/web-server/src/ch-sessions.ts | 43 ++++- .../test/browser/ch-auto-history.test.ts | 131 ++++++++++++++ .../test/browser/ch-history.test.ts | 29 +++- .../test/browser/ch-sessions.test.ts | 3 + .../test/browser/ch-switching.test.ts | 76 ++++++++ .../test/browser/turn-navigation.test.ts | 162 ++++++++++++++++++ .../test/ch-catalog-updates.test.ts | 72 ++++++++ 19 files changed, 877 insertions(+), 96 deletions(-) create mode 100644 packages/web-server/test/browser/ch-auto-history.test.ts create mode 100644 packages/web-server/test/browser/ch-switching.test.ts create mode 100644 packages/web-server/test/browser/turn-navigation.test.ts create mode 100644 packages/web-server/test/ch-catalog-updates.test.ts diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts index 2caeccc26..1e620360f 100644 --- a/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/session.ts @@ -126,7 +126,7 @@ export interface ISession { * Extend history by at least 50 messages and two Turn starts, including a * partial Turn at the window's beginning. Stop at 500 messages or history * exhaustion even when those minima cannot be met. Publish one prepend. - * @returns completion; failures land in snapshot.openState/loadingOlder. + * @returns completion; failures land in snapshot.olderError without discarding the open history. */ loadOlder(): Promise; /** diff --git a/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts index de5514721..e1910cf33 100644 --- a/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/apps/web-ui/packages/api/session-controller/src/client/contract/snapshot.ts @@ -80,6 +80,8 @@ export interface SessionSnapshot { readonly openError: RemoteFailure | null; readonly hasMore: boolean; readonly loadingOlder: boolean; + /** An older-page failure leaves the current conversation usable and retryable. */ + readonly olderError: RemoteFailure | null; readonly promptError: PromptError | null; readonly blank: boolean; readonly lastAgentError: string | null; diff --git a/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts index ec27dfff1..98e6b4937 100644 --- a/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts +++ b/apps/web-ui/packages/api/session-controller/src/client/sessions/session.ts @@ -116,6 +116,7 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0; private loadingOlder = false; + private olderError: RemoteFailure | null = null; /** Shared low-water target of the running jump loop; null when no jump is paging. */ private jumpTargetSeq: SessionSeq | null = null; /** The running jump loop's completion, shared by retargeting callers. */ @@ -449,7 +450,9 @@ export class Session implements SessionFace { if (this.openState !== "open" || !this.hasMore || this.loadingOlder) return; const events = this.events; if (events === undefined) return; + const generation = this.openGeneration; this.loadingOlder = true; + this.olderError = null; this.notifier.markDirty(); try { await events.prepend({ @@ -457,12 +460,15 @@ export class Session implements SessionFace { ...HISTORY_PAGE_OPTIONS, }); } catch (error) { - if (!isRemoteFailure(error)) { - console.error("[session-controller] loadOlder failed:", error); - } + if (generation === this.openGeneration) + this.olderError = isRemoteFailure(error) + ? error + : new RemoteError("gateway/internal", "Could not load earlier history.", {}); } finally { - this.loadingOlder = false; - this.notifier.markDirty(); + if (generation === this.openGeneration) { + this.loadingOlder = false; + this.notifier.markDirty(); + } } } @@ -488,6 +494,7 @@ export class Session implements SessionFace { }; this.pendingHistory = pending; this.jumpTargetSeq = seq; + this.olderError = null; this.loadingOlder = true; this.notifier.markDirty(); // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the @@ -664,6 +671,8 @@ export class Session implements SessionFace { private async doOpen(generation: number): Promise { this.openState = "loading"; this.openError = null; + this.olderError = null; + this.loadingOlder = false; this.notifier.markDirty(); const events = new SessionEventStream(this.remote, this.sessionAddress(), { publish: (change) => { @@ -995,6 +1004,7 @@ export class Session implements SessionFace { openError: this.openError, hasMore: this.hasMore, loadingOlder: this.loadingOlder, + olderError: this.olderError, promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, diff --git a/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.module.css b/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.module.css index d1ce5ea00..bb7922d5f 100644 --- a/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -218,6 +218,12 @@ .older { display: flex; justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-height: 28px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); } .older button { diff --git a/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.tsx b/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3b822ebd0..79b4e669c 100644 --- a/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/apps/web-ui/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -216,6 +216,7 @@ export function ChatView({ const openError = useSession((s) => s.openError); const hasMore = useSession((s) => s.hasMore); const loadingOlder = useSession((s) => s.loadingOlder); + const olderError = useSession((s) => s.olderError); const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>( null, ); @@ -312,6 +313,7 @@ export function ChatView({ lastKey, running, loadingOlder, + olderFailed: olderError != null, hasMore, chatScroll, loadOlder, @@ -344,9 +346,20 @@ export function ChatView({ )} {hasMore && (
- + {olderError != null ? ( + <> + + {t("chat.loadOlderError", { message: olderError.message })} + + + + ) : ( + + {loadingOlder ? t("chat.loadingOlder") : t("chat.scrollOlder")} + + )}
)} { readonly active: boolean; readonly busy: boolean; readonly previewId: string | undefined; + readonly neighborDistance: number | undefined; readonly registerElement: (element: HTMLButtonElement | null) => void; readonly onPreview: (turn: number | null) => void; readonly onFocusChange: (turn: number | null) => void; @@ -71,6 +73,7 @@ const TurnMark = memo(function TurnMark({ active, busy, previewId, + neighborDistance, registerElement, onNavigate, onPreview, @@ -80,23 +83,24 @@ const TurnMark = memo(function TurnMark({ const classes = [css.mark]; if (item.anchor.kind === "unloaded") classes.push(css.markUnloaded); if (active) classes.push(css.markActive); - else if (previewId !== undefined) classes.push(css.markPreview); + if (neighborDistance === 0) classes.push(css.markPreview); if (busy) classes.push(css.markBusy); return (
@@ -669,6 +692,7 @@ export function SessionNodeItem({ // their drop targets to fellow pinned rows. const draggable = drag !== undefined && !row.blank && !row.archived; const [menuOpen, setMenuOpen] = useState(false); + const [contextPoint, setContextPoint] = useState(null); // The menu's open state, bound into the row entries' `useMenuOpenState` hook. const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]); const rowRef = useRef(null); @@ -694,11 +718,32 @@ export function SessionNodeItem({ )} role="treeitem" aria-selected={selected} + aria-current={selected ? "page" : undefined} + tabIndex={-1} + onContextMenu={(e) => { + if (row.blank || (e.target as HTMLElement).closest("button")) return; + e.preventDefault(); + setContextPoint(new DOMRect(e.clientX, e.clientY, 0, 0)); + setMenuOpen(true); + }} + onKeyDown={(e) => { + if (e.target !== e.currentTarget || row.blank) return; + if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + setContextPoint(null); + setMenuOpen(true); + } else if (e.key === "F2" && !row.archived) { + e.preventDefault(); + onRenameRequest(node.id, row.title); + } + }} aria-description={row.archived ? t("toast.archivedNotOpenable") : undefined} onClick={() => { onOpen(node.id); }} - onPointerEnter={marquee.enter} + onPointerEnter={(e) => { + if (e.pointerType === "mouse") marquee.enter(); + }} onPointerLeave={marquee.leave} draggable={draggable} onDragStart={ @@ -750,6 +795,7 @@ export function SessionNodeItem({ {title} - {/* A blank New Session row is a provisional placeholder: nothing has + + {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs (rename/fork/archive) would all act on content that does not exist — both trailing cells stay off until the first prompt. */} - {!row.blank && ( - - {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} - - )} - {/* Trails the time so the marker occupies the same right-edge cell as + {!row.blank && ( + + {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} + + )} + {/* Trails the time so the marker occupies the same right-edge cell as the hover pin button that replaces it. */} - {row.pinned && !row.archived && } - {/* The strip's clicks stay in the strip: the trigger and every + {row.pinned && !row.archived && } + {/* The strip's clicks stay in the strip: the trigger and every row.action entry act without also opening the row, so an entry's button needs no propagation handling of its own. */} - {!row.blank && ( - { - e.stopPropagation(); - }} - > - { - setMenuOpen(false); + {!row.blank && ( + { + e.stopPropagation(); }} - portal - closeOnPointerLeave - anchor={ - - } > - {renderSlot( - "sidebar.workspaces.session.menu.item", - { sessionId: node.id, displayTitle: row.title }, - { hookContext: menuOpenState }, - )} - - {renderSlot("sidebar.workspaces.session.row.action", { - sessionId: node.id, - displayTitle: row.title, - })} - - )} + { + setMenuOpen(false); + }} + portal + autoFocus + getAnchorRect={contextPoint === null ? undefined : () => contextPoint} + anchor={ + + } + > + {renderSlot( + "sidebar.workspaces.session.menu.item", + { sessionId: node.id, displayTitle: row.title }, + { hookContext: menuOpenState }, + )} + + + {renderSlot("sidebar.workspaces.session.row.action", { + sessionId: node.id, + displayTitle: row.title, + })} + + + )} +
); return ( diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 120f256ab..438b70831 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -59,9 +59,9 @@ gap: 4px; height: 36px; padding-left: 4px; - margin-bottom: 4px; + margin-bottom: 6px; box-sizing: border-box; - border-radius: var(--dsw-radius-md); + border-radius: 6px; overflow: hidden; color: var(--dsw-alias-label-tertiary); } @@ -72,6 +72,8 @@ } .sectionLabel { + font-size: 12px; + font-weight: 500; flex: none; max-width: 45%; min-width: 0; @@ -362,6 +364,8 @@ flex: 1; min-height: 0; overflow-y: auto; + overscroll-behavior-y: contain; + scroll-padding-block: 8px; margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); padding-left: 4px; @@ -378,7 +382,7 @@ .flatList > * + *, .searchTree > [role="treeitem"] + [role="treeitem"], .groupSection > * + * { - margin-top: 2px; + margin-top: 1px; } .searchStatus { @@ -454,7 +458,7 @@ } .groupSection + .groupSection { - margin-top: 4px; + margin-top: 12px; } .listTopDropIndicator, @@ -508,10 +512,10 @@ .sessionOverflowButton { width: 100%; - height: 28px; + height: 32px; border: none; - border-radius: var(--dsw-radius-sm); - padding: 0 12px 0 calc(28px + var(--dsh-workspace-indent, 0px)); + border-radius: 6px; + padding: 0 12px 0 calc(32px + var(--dsh-workspace-indent, 0px)); background: transparent; cursor: pointer; text-align: left; @@ -524,10 +528,25 @@ } .sessionOverflowButton:hover { - background: transparent; + background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } +.sessionOverflowButton:focus-visible { + outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} + +@media (hover: none), (pointer: coarse) { + .sessionOverflowButton, + .root:not(.rail) .iconButton { + min-height: 44px; + } + .root:not(.rail) .sectionHeader { + height: 44px; + } +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 4f02182ad..5782ef82c 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -18,19 +18,20 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useStat import clsx from "clsx"; import { Button, - IconArchiveCheckOutlineRegular, - IconArchiveOffOutlineRegular, - IconArchiveOutlineRegular, - IconChevronsUpDownOutlineRegular, - IconClockOutlineRegular, - IconCloseFillRegular, - IconFlatListOutlineRegular, + OpenAIArchiveIcon as IconArchiveCheckOutlineRegular, + OpenAIEyeOffIcon as IconArchiveOffOutlineRegular, + OpenAIArchiveIcon as IconArchiveOutlineRegular, + OpenAISortIcon as IconChevronsUpDownOutlineRegular, + OpenAIClockIcon as IconClockOutlineRegular, + OpenAICloseIcon as IconCloseFillRegular, + OpenAIListIcon as IconFlatListOutlineRegular, IconFolderCloseRegular, - IconProjectAddOutlineRegular, - IconQueueOutlineRegular, - IconSearchOutlineRegular, - IconSlidersTwoOutlineRegular, - IconWorkspaceTreeOutlineRegular, + OpenAIFolderAddIcon as IconProjectAddOutlineRegular, + OpenAIListIcon as IconQueueOutlineRegular, + OpenAISearchIcon as IconSearchOutlineRegular, + OpenAIViewOptionsIcon as IconSlidersTwoOutlineRegular, + OpenAIFoldersIcon as IconWorkspaceTreeOutlineRegular, + OpenAIEyeIcon, Menu, Modal, Toast, @@ -66,6 +67,7 @@ import { } from "../tree.ts"; import { ProjectRowItem, SearchResultItem, SessionNodeItem } from "./Rows.tsx"; import { AnimatedRows } from "./AnimatedRows.tsx"; +import { GROUP_INITIAL_ROWS, SIDEBAR_PAGE_ROWS, sidebarWindow } from "./sidebar-window.ts"; import { FLAT_SESSION_ORDER_KEY, type SessionGroupBy } from "../stores.ts"; import { WorkspacePickFlow } from "../WorkspacePicker.tsx"; import css from "./WorkspaceBrowser.module.css"; @@ -79,27 +81,6 @@ const EXPAND_SLIDE_MS = 300; const SEARCH_DEBOUNCE_MS = 250; /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500; -/** Idle Session rows visible per Workspace before the local overflow control. */ -const COLLAPSED_SESSION_LIMIT = 5; - -/** Keep provisional and running rows outside the idle-session quota, including parents with running children. */ -function collapsedSessionRows( - sessions: readonly SessionNode[], - limit = COLLAPSED_SESSION_LIMIT, -): { - rows: readonly SessionNode[]; - hiddenCount: number; -} { - let idleCount = 0; - const rows = sessions.filter((session) => { - if (session.blank || session.running || session.runningSubagentCount > 0) return true; - if (idleCount >= limit) return false; - idleCount += 1; - return true; - }); - return { rows, hiddenCount: sessions.length - rows.length }; -} - /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { const withoutNul = value.replaceAll("\0", ""); @@ -183,7 +164,7 @@ function ViewOptionsMenu({ { id: "show-archived", label: t("viewOptions.showArchived"), - icon: , + icon: , }, { id: "only-archived", @@ -459,20 +440,6 @@ function SessionTree({ } } }, [groupExpansion, parents, revealGroup, setGroupExpanded]); - useEffect(() => { - if (revealSessionId === undefined || revealGroup === undefined) return; - const group = groups.find((candidate) => candidate.key === revealGroup); - if ( - group === undefined || - !group.expanded || - !group.sessions.some((row) => row.id === revealSessionId) - ) - return; - if (collapsedSessionRows(group.sessions).rows.some((row) => row.id === revealSessionId)) return; - setSessionLimits((limits) => - limits[revealGroup] === Infinity ? limits : { ...limits, [revealGroup]: Infinity }, - ); - }, [groups, revealGroup, revealSessionId]); const now = Date.now(); const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return; @@ -487,7 +454,12 @@ function SessionTree({ : workspaces.find((workspace) => workspace.workspaceId === activeDrag.accountKey) ?.sessionIds; if (accountSessionIds === undefined) return; - const renderedSessions = collapsedSessionRows(group.sessions, sessionLimits[group.key]).rows; + const renderedSessions = sidebarWindow( + group.sessions, + sessionLimits[group.key] ?? GROUP_INITIAL_ROWS, + current, + revealSessionId, + ).rows; const nextOrder = sessionDragOrder(accountSessionIds, renderedSessions, activeDrag, over); if (nextOrder !== undefined) setSessionOrder(activeDrag.accountKey, nextOrder); }; @@ -543,8 +515,13 @@ function SessionTree({ const children = childrenByParent.get(group.key) ?? []; const compatibleDrag = workspaceDrag !== null && parents.get(workspaceDrag.workspaceId) === parents.get(group.key); - const collapsed = collapsedSessionRows(group.sessions); - const visible = collapsedSessionRows(group.sessions, sessionLimits[group.key]); + const collapsed = sidebarWindow(group.sessions, GROUP_INITIAL_ROWS, current, revealSessionId); + const visible = sidebarWindow( + group.sessions, + sessionLimits[group.key] ?? GROUP_INITIAL_ROWS, + current, + revealSessionId, + ); const sessionsExpanded = visible.hiddenCount === 0; rowKeys.push(`workspace:${group.key}`); const childRows = group.expanded ? children.map((child) => renderGroup(child, depth + 1)) : []; @@ -592,6 +569,7 @@ function SessionTree({ // (WorkspaceBrowser.module.css).
{ if (group.expanded) { - setSessionLimits((limits) => ({ ...limits, [group.key]: COLLAPSED_SESSION_LIMIT })); + setSessionLimits((limits) => ({ ...limits, [group.key]: GROUP_INITIAL_ROWS })); } setGroupExpanded(group.key, !group.expanded); }} @@ -741,16 +719,14 @@ function SessionTree({ setSessionLimits((limits) => ({ ...limits, [group.key]: sessionsExpanded - ? COLLAPSED_SESSION_LIMIT - : visible.hiddenCount <= COLLAPSED_SESSION_LIMIT - ? Infinity - : (limits[group.key] ?? COLLAPSED_SESSION_LIMIT) + COLLAPSED_SESSION_LIMIT, + ? GROUP_INITIAL_ROWS + : (limits[group.key] ?? GROUP_INITIAL_ROWS) + SIDEBAR_PAGE_ROWS, })); }} > {sessionsExpanded ? t("sessions.collapse") - : t("sessions.expand", { n: visible.hiddenCount })} + : t("sessions.expand", { n: Math.min(SIDEBAR_PAGE_ROWS, visible.hiddenCount) })} )}
@@ -822,10 +798,14 @@ function FlatList({ ); const [drag, setDrag] = useState(null); const dropCommitted = useRef(false); + const [limit, setLimit] = useState(SIDEBAR_PAGE_ROWS); useNativeDragAcceptance(drag !== null); const currentId = panelActive ? undefined : Object.values(list.byId).find((session) => (session.retainedBy.mainView ?? 0) > 0)?.id; + const visible = sidebarWindow(rows, limit, currentId, revealSessionId); + const hasOverflow = + sidebarWindow(rows, SIDEBAR_PAGE_ROWS, currentId, revealSessionId).hiddenCount > 0; const commitDrag = (activeDrag: DragState, over: NonNullable): void => { if (dropCommitted.current) return; dropCommitted.current = true; @@ -839,14 +819,21 @@ function FlatList({ `session:${row.id}`)} + rowKeys={ + rows.length === 0 + ? ["empty"] + : [ + ...visible.rows.map((row) => `session:${row.id}`), + ...(hasOverflow ? ["overflow:flat"] : []), + ] + } ready={list.phase === "ready" && workspaceReady && drag === null} - resetKey={animationResetKey} + resetKey={`${animationResetKey}/${limit}`} > {rows.length === 0 && ( )} - {rows.map((node) => { + {visible.rows.map((node) => { const active = drag !== null && drag.pinned === node.pinned; const normalizeHalf = (half: "before" | "after"): "before" | "after" => node.blank ? "after" : half; @@ -901,6 +888,21 @@ function FlatList({ /> ); })} + {hasOverflow && ( + + )} @@ -1509,7 +1511,7 @@ export function WorkspaceBrowser({ requestSearch(); }} > - + ("[data-row-key]")].filter( + (row) => row.offsetHeight > 0 && !row.closest("[inert]"), + ); +} + +/** One Tab entry per tree. Tab within a focused row still reaches its actions. */ +export function syncSidebarTabStop(tree: HTMLElement | null): void { + if (!tree) return; + const rows = rowsIn(tree); + const focused = document.activeElement?.closest("[data-row-key]"); + const entry = + rows.find((row) => row === focused) ?? + rows.find((row) => row.getAttribute("aria-selected") === "true") ?? + rows[0]; + for (const row of rows) row.tabIndex = row === entry ? 0 : -1; +} + +/** Focus movement does not select or read a Thread until Enter/Space activates it. */ +export function navigateSidebar(event: KeyboardEvent): void { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return; + const tree = event.currentTarget; + const row = event.target; + // Menu buttons and portaled menu items own their keyboard behavior. + if (!(row instanceof HTMLElement) || !row.matches("[data-row-key]") || !tree.contains(row)) + return; + const rows = rowsIn(tree); + const index = rows.indexOf(row); + if (index < 0) return; + const groupRow = row.matches('[role="treeitem"][aria-expanded]'); + let next: HTMLElement | undefined; + switch (event.key) { + case "ArrowDown": + next = rows[index + 1]; + break; + case "ArrowUp": + next = rows[index - 1]; + break; + case "Home": + next = rows[0]; + break; + case "End": + next = rows.at(-1); + break; + case "ArrowRight": + if (groupRow && row.getAttribute("aria-expanded") === "false") row.click(); + else if (groupRow) next = rows[index + 1]; + break; + case "ArrowLeft": { + if (groupRow && row.getAttribute("aria-expanded") === "true") row.click(); + else { + let group = row.closest("[data-sidebar-group]"); + let heading = group?.querySelector('[data-row-key^="workspace:"]'); + if (heading === row) { + group = group?.parentElement?.closest("[data-sidebar-group]") ?? null; + heading = group?.querySelector('[data-row-key^="workspace:"]'); + } + next = heading ?? undefined; + } + break; + } + case "Enter": + case " ": + row.click(); + break; + default: + return; + } + event.preventDefault(); + if (next) { + next.focus({ preventScroll: true }); + next.scrollIntoView({ block: "nearest" }); + syncSidebarTabStop(tree); + } +} diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/sidebar-window.ts b/apps/web-ui/packages/client/ui-workspace/src/client/rows/sidebar-window.ts new file mode 100644 index 000000000..699c49455 --- /dev/null +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/sidebar-window.ts @@ -0,0 +1,27 @@ +/** A presentation window over the existing catalog, not a second history or paging source. */ +interface SidebarRow { + id: string; + blank: boolean; + pinned: boolean; + running: boolean; + runningSubagentCount: number; +} + +export const GROUP_INITIAL_ROWS = 5; +export const SIDEBAR_PAGE_ROWS = 10; + +/** Important rows remain reachable without expanding every older conversation. */ +export function sidebarWindow( + sessions: readonly T[], + limit: number, + currentId?: string, + revealId?: string, +): { rows: readonly T[]; hiddenCount: number } { + let ordinary = 0; + const rows = sessions.filter((session) => { + if (session.blank || session.pinned || session.running || session.runningSubagentCount > 0) + return true; + return ordinary++ < limit || session.id === currentId || session.id === revealId; + }); + return { rows, hiddenCount: sessions.length - rows.length }; +} diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx index 2bccd9f7d..020ad0783 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx @@ -15,8 +15,8 @@ import type {} from "@deepseek-ai/dsh-schedule/client"; import type {} from "@deepseek-ai/dsh-subagent/client"; import { Button, - IconArchiveOutlineRegular, - IconUnarchiveOutlineRegular, + OpenAIArchiveIcon as IconArchiveOutlineRegular, + OpenAIUnarchiveIcon as IconUnarchiveOutlineRegular, MenuItemButton, Modal, Tooltip, @@ -54,9 +54,9 @@ export function ArchiveSessionMenuItem({ shortcut={archived ? undefined : shortcut} icon={ archived ? ( - + ) : ( - + ) } onSelect={() => { @@ -98,9 +98,9 @@ export function ArchiveSessionRowButton({ }} > {archived ? ( - + ) : ( - + )} diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx index b4eee3fed..0e78408d0 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -1,5 +1,8 @@ /** The fork action: one `sidebar.workspaces.session.menu.item` row. */ -import { IconBranchOutlineRegular, MenuItemButton } from "@deepseek-ai/dsh-client-ui-primitives"; +import { + OpenAIBranchIcon as IconBranchOutlineRegular, + MenuItemButton, +} from "@deepseek-ai/dsh-client-ui-primitives"; import type { ForkSessionInjected, SessionMenuItemProps } from "../contract/slots.ts"; /** diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx index e876a7d7c..c707844f7 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx @@ -7,8 +7,8 @@ * notice) lives in the injected callbacks, not here. */ import { - IconPinFillRegular, - IconPinOutlineRegular, + OpenAIPinFilledIcon as IconPinFillRegular, + OpenAIPinIcon as IconPinOutlineRegular, MenuItemButton, Tooltip, } from "@deepseek-ai/dsh-client-ui-primitives"; @@ -79,7 +79,7 @@ export function PinSessionRowButton(props: SessionRowActionProps - {pinned ? : } + {pinned ? : } ); diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx index f0e530bd3..29ca82ae2 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx @@ -7,7 +7,7 @@ import { useRef, useState } from "react"; import { Button, - IconEditOutlineRegular, + OpenAIEditIcon as IconEditOutlineRegular, MenuItemButton, Modal, } from "@deepseek-ai/dsh-client-ui-primitives"; diff --git a/apps/web-ui/packages/client/ui-workspace/test/sidebar-window.test.ts b/apps/web-ui/packages/client/ui-workspace/test/sidebar-window.test.ts new file mode 100644 index 000000000..2acb1a565 --- /dev/null +++ b/apps/web-ui/packages/client/ui-workspace/test/sidebar-window.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { sidebarWindow } from "../src/client/rows/sidebar-window.ts"; + +const rows = () => + Array.from({ length: 40 }, (_, i) => ({ + id: String(i), + blank: false, + pinned: false, + running: false, + runningSubagentCount: 0, + })); + +it("keeps the current and search target visible without expanding all intervening history", () => { + const result = sidebarWindow(rows(), 5, "38", "39"); + assert.deepEqual( + result.rows.map((row) => row.id), + ["0", "1", "2", "3", "4", "38", "39"], + ); + assert.equal(result.hiddenCount, 33); +}); +it("selecting within the initial window does not reveal another row or duplicate the selection", () => { + assert.equal(sidebarWindow(rows(), 5, "2", "2").rows.length, 5); +}); +it("retains pinned, provisional and active conversations outside the ordinary quota", () => { + const source = rows(); + const pinned = source[10], + blank = source[11], + running = source[12], + parent = source[13]; + assert.ok(pinned && blank && running && parent); + pinned.pinned = true; + blank.blank = true; + running.running = true; + parent.runningSubagentCount = 1; + const result = sidebarWindow(source, 5); + assert.deepEqual( + result.rows.map((row) => row.id), + ["0", "1", "2", "3", "4", "10", "11", "12", "13"], + ); + assert.equal(result.hiddenCount, 31); +}); +it("extends a presentation window by ten without mutating the catalog order", () => { + const source = rows(); + const before = structuredClone(source); + assert.equal(sidebarWindow(source, 15).rows.length, 15); + assert.deepEqual(source, before); + assert.equal(sidebarWindow(source, 50).hiddenCount, 0); + assert.deepEqual(sidebarWindow([], 5), { rows: [], hiddenCount: 0 }); +}); diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index 3b1eb136a..fd3cfef63 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -44,6 +44,16 @@ npm run start:web -- --port 3181 当前共享入口保留文本发送、停止、原生模型/权限选择及改名;需要审批的请求由现有 GUI 处理。Web 未接入共享 Host 的审批响应/实时通知、Steering 和完整子会话操作,不把这些入口包装成已经完成的实时双端同步。独立模式原有功能不受此限制。 +## 会话侧栏 + +侧栏参考 Codex 的紧凑导航布局:弱化项目标题与时间,区分当前选中行和悬停行,保留 Harness 身份。元信息和快捷操作共用固定尾部区域,悬停、键盘聚焦或打开菜单不挤动标题;长标题停留后缓慢展开,减少动态效果时保持静止。新会话使用轻量导航行而非突出卡片。 + +项目分组初始显示 5 个普通会话,平铺初始显示 10 个,每次再显示 10 个;置顶、草稿、运行中、当前会话及搜索定位目标保持可见,不为定位一个旧会话展开全部历史。这是已有目录的前端展示窗口,不是 Host 目录分页;仍使用同一目录、项目归属和原生 Thread ID。箭头键移动焦点,Enter/空格打开,左右键展开/收起项目,Home/End 定位首尾;移动焦点不会预读正文。右键或 Shift+F10 打开现有操作菜单,Escape/外部点击关闭,不因鼠标短暂离开而消失。手机端显示明确的菜单按钮,主要会话行及菜单按钮至少 44px,快捷归档/置顶收进菜单以避免误触。 + +项目行与分组菜单的文件夹图标保留 DSH 原有的轻量 16px 版本,项目行随展开/收起切换打开/闭合造型,悬停时换为展开箭头。其他通用图标使用 OpenAI 公开的 MIT SVG 图标子集:展开箭头、新会话、搜索、视图选项、置顶、归档、改名、分叉、导入、设置及侧栏开关。保留原始 SVG 轮廓和填充规则,随当前文字颜色适配主题。只引入必要 SVG 数据,不引入整套 Apps SDK,不打包私有 Desktop 动画资源;公开版本的个别字形与当前 Desktop 私有版本并非逐像素相同。Harness 品牌、权限含义及真实状态不替换为 Codex 身份,远程图标/连接状态也不凭截图伪造。版权与许可文本随 Web 发行打包。 + +状态继续来自已有投影;未接入的原生审批/未读能力不伪造。正文通用运行文案使用“正在处理 / Working”,不暗示某个 Model、推理模式或深度研究能力。 + ## 对话轮次导航 正文左侧的轮次导航参考已安装 Codex 的 `thread-user-message-navigation-rail` 与 `floating-navigation-rail-layout` 行为实现:距离正文滚动区左边缘 12px、每项间距 10px;根据正文实际左侧留白判断,少于 48px 时隐藏,不挤占窄屏内容。平时为短刻度,当前阅读轮次加深;悬停目标及相邻三项按距离依次加长。预览向右展开,限制一行提问和三行回复,悬停短暂延迟后出现,键盘聚焦立即显示。 @@ -79,6 +89,7 @@ npm run typecheck npm run test:web npm run test:web:distribution npm run test:web:browser +npm run test:sidebar --workspace=@deepseek-ai/dsh-client-ui-workspace ``` 服务端和浏览器测试使用假 Harness、模拟 CH Host/CDP 与临时数据,不提交真实模型请求。共享来源检查覆盖 canonical 身份、已有 GUI 历史、Web 创建、自动目录分组和不新增会话文件;真实环境仅进行目录/元数据和页面只读检查。浏览器检查覆盖手机尺寸的设置、主题持久化、发送、断网重连和历史恢复;发行检查覆盖仓库外运行、插件资源和隔离启动参数。迁入的 Codex Item 映射测试由仓库 Vitest 配置运行。 diff --git a/packages/web-server/scripts/pack.ts b/packages/web-server/scripts/pack.ts index d470a9b29..9ca3c5256 100644 --- a/packages/web-server/scripts/pack.ts +++ b/packages/web-server/scripts/pack.ts @@ -135,6 +135,7 @@ for (const plugin of extraAdapters) { cpSync(join(repoRoot, "LICENSE"), join(out, "LICENSE")); mkdirSync(join(out, "licenses"), { recursive: true }); cpSync(join(frontendRoot, "LICENSE"), join(out, "licenses/DSH-MIT.txt")); +cpSync(join(frontendRoot, "OPENAI_ICONS_LICENSE.txt"), join(out, "licenses/OPENAI-ICONS-MIT.txt")); cpSync(join(frontendRoot, "THIRD_PARTY_NOTICES.md"), join(out, "licenses/THIRD_PARTY_NOTICES.md")); writeFileSync( join(out, "package.json"), @@ -180,6 +181,7 @@ The default listener is localhost. For phones, use an HTTPS reverse proxy such a PWA installation and Web Push need HTTPS. Do not expose \`--no-auth\` to a network. This distribution includes LGPL-licensed CodexHost code and MIT-licensed DSH-derived UI. +Navigation SVGs include a subset of @openai/apps-sdk-ui 0.2.2 (MIT, Copyright 2025 OpenAI). Retained license texts and dependency notices are in LICENSE and licenses/. `, ); diff --git a/packages/web-server/test/browser/ch-sessions.test.ts b/packages/web-server/test/browser/ch-sessions.test.ts index c24b980d9..b404d85a2 100644 --- a/packages/web-server/test/browser/ch-sessions.test.ts +++ b/packages/web-server/test/browser/ch-sessions.test.ts @@ -99,6 +99,9 @@ for (const width of [390, 1280]) .getByRole("button", { name: "New session", exact: true }) .filter({ hasText: "New Session" }) .click(); + // Wait for the requested draft, rather than typing into the previous + // Thread's still-mounted composer during asynchronous navigation. + await page.getByText("What should we build today?", { exact: true }).waitFor(); await page .getByRole("textbox", { name: "Describe what you want to build, / commands, @ files or sessions", @@ -106,7 +109,17 @@ for (const width of [390, 1280]) }) .fill("created in Web"); await page.getByRole("button", { name: "Send message", exact: true }).click(); - await page.getByText("CH response: created in Web", { exact: true }).waitFor(); + await page + .getByText("CH response: created in Web", { exact: true }) + .waitFor() + .catch(async (error: unknown) => { + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `ch-send-failure-${width}.png`), + }); + } + throw error; + }); assert.equal(host.requests.filter((r) => r.method === "thread/start").length, 1); const newThread = [...host.threads.values()].find((value) => value.id.startsWith("canonical-"), @@ -149,6 +162,8 @@ it( t.after(() => browser.close()); const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); await page.goto(url); + await page.waitForLoadState("networkidle"); + await page.getByText("Native Model", { exact: true }).waitFor(); const folder = page.getByRole("treeitem").filter({ hasText: /^project$/ }); await folder.waitFor(); if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); diff --git a/packages/web-server/test/browser/ch-sidebar.test.ts b/packages/web-server/test/browser/ch-sidebar.test.ts new file mode 100644 index 000000000..df4f564e4 --- /dev/null +++ b/packages/web-server/test/browser/ch-sidebar.test.ts @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { chromium, type Page } from "playwright"; +import { FakeChHost, startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; + +async function shot(page: Page, name: string): Promise { + if (!process.env.CODEXHOST_TEST_SCREENSHOTS) return; + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, `${name}.png`), + animations: "disabled", + }); +} + +for (const mobile of [false, true]) { + it( + `Codex-style sidebar layout and interactions (${mobile ? "touch" : "desktop"})`, + { timeout: 60_000 }, + async (t) => { + const host = new FakeChHost(); + for (let i = 0; i < 28; i++) { + host.add( + `sidebar-${i}`, + "/computer/product", + `Conversation ${String(i).padStart(2, "0")} — review the workspace interaction and layout`, + ); + } + const debug = await startFakeChDebugger(host); + t.after(() => debug.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + ["--session-source", "codexhost", "--ch-cdp", debug.endpoint], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ + viewport: { width: mobile ? 390 : 1280, height: 900 }, + isMobile: mobile, + hasTouch: mobile, + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await page.waitForLoadState("networkidle"); + // Default draft selection/configuration must settle before opening the drawer. + await page.getByText("Native Model", { exact: true }).waitFor(); + const openSidebar = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (mobile) await openSidebar.first().click(); + const folder = page.getByRole("treeitem").filter({ hasText: /^product$/ }); + await folder.waitFor(); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + const rows = page.locator('[data-row-key^="session:sidebar-"]'); + await rows.first().waitFor(); + assert.equal(await rows.count(), 5); + if (!mobile) await page.mouse.move(800, 800); + const folderIcon = folder.locator("svg").first(); + assert.equal(await folder.locator('[data-openai-icon="Folder"]').count(), 0); + assert.equal(await folderIcon.getAttribute("viewBox"), "0 0 16 16"); + assert.equal(await folderIcon.getAttribute("width"), "16"); + assert.equal(await folderIcon.getAttribute("stroke-width"), "1"); + assert.equal(await folderIcon.getAttribute("aria-hidden"), "true"); + const expandedFolderPath = await folderIcon.locator("path").first().getAttribute("d"); + await shot(page, `sidebar-${mobile ? "touch" : "desktop"}-rest`); + const row = rows.first(); + await row.locator('[data-harness-id="fake"]').waitFor(); + assert.equal(await row.locator('[data-openai-icon="DotsHorizontal"]').count(), 1); + assert.equal(await row.locator('[data-openai-icon="Archive"]').count(), 1); + assert.equal(await row.locator('[data-openai-icon="Pin"]').count(), 1); + const trigger = row.getByRole("button", { name: /^Session actions for/ }); + if (mobile) { + const bounds = await trigger.boundingBox(); + assert.ok( + bounds && bounds.width >= 44 && bounds.height >= 44, + "touch menu is a real 44px target", + ); + assert.equal( + await row.getByRole("button", { name: "Archive session", exact: true }).isVisible(), + false, + ); + await trigger.tap(); + await page.getByRole("menu").waitFor(); + await shot(page, "sidebar-touch-menu"); + assert.equal(await row.getAttribute("aria-selected"), "false", "menu does not navigate"); + await page.keyboard.press("Escape"); + await page.getByRole("menu").waitFor({ state: "hidden" }); + await row.tap(); + await page.getByText("existing CH answer", { exact: true }).waitFor(); + await shot(page, "sidebar-touch-conversation"); + await openSidebar.last().click(); + await shot(page, "sidebar-touch-selected"); + } else { + const title = row.locator("[data-session-title]"); + const resting = await title.boundingBox(); + await row.hover(); + await trigger.waitFor(); + const hovered = await title.boundingBox(); + assert.ok( + resting && hovered && Math.abs(resting.width - hovered.width) < 1, + "hover must not squeeze the title", + ); + await shot(page, "sidebar-desktop-hover"); + await row.getByRole("button", { name: "Pin session", exact: true }).click(); + await row.getByRole("button", { name: "Unpin session", exact: true }).waitFor(); + assert.ok((await row.locator('[data-openai-icon="PinFilled"]').count()) > 0); + await shot(page, "sidebar-desktop-pinned"); + await row.getByRole("button", { name: "Unpin session", exact: true }).click(); + await row.getByRole("button", { name: "Pin session", exact: true }).waitFor(); + await row.click({ button: "right" }); + await page.getByRole("menu").waitFor(); + await page.mouse.move(1000, 800); + await page.waitForTimeout(600); + assert.equal( + await page.getByRole("menu").isVisible(), + true, + "menu stays open when the pointer leaves", + ); + await shot(page, "sidebar-desktop-context-menu"); + assert.equal(await row.getAttribute("aria-selected"), "false"); + await page.keyboard.press("Escape"); + await page.getByRole("menu").waitFor({ state: "hidden" }); + await row.click(); + await page.getByText("existing CH answer", { exact: true }).waitFor(); + await row.press("ArrowDown"); + assert.equal( + await row.getAttribute("aria-selected"), + "true", + "focus movement does not navigate", + ); + assert.equal( + await rows.nth(1).evaluate((element) => element === document.activeElement), + true, + ); + await page.keyboard.press("Enter"); + assert.equal(await rows.nth(1).getAttribute("aria-current"), "page"); + await shot(page, "sidebar-desktop-keyboard"); + await page.getByRole("button", { name: "Show 10 more", exact: true }).click(); + assert.equal(await rows.count(), 15); + await page.getByRole("button", { name: "Show 10 more", exact: true }).click(); + assert.equal(await rows.count(), 25); + await page.getByRole("button", { name: "Show 3 more", exact: true }).click(); + assert.equal(await rows.count(), 28); + const lastKey = await rows.last().getAttribute("data-row-key"); + await rows.last().click(); + await page.getByRole("button", { name: "Show less", exact: true }).click(); + assert.equal( + await rows.count(), + 6, + "selected older Thread survives collapse without all intermediate rows", + ); + assert.equal( + await page.locator(`[data-row-key="${lastKey}"]`).getAttribute("aria-selected"), + "true", + ); + await shot(page, "sidebar-selected-older"); + await folder.press("ArrowLeft"); + assert.equal(await folder.getAttribute("aria-expanded"), "false"); + assert.notEqual( + await folderIcon.locator("path").first().getAttribute("d"), + expandedFolderPath, + ); + await folder.press("ArrowRight"); + assert.equal(await folder.getAttribute("aria-expanded"), "true"); + assert.equal( + await folderIcon.locator("path").first().getAttribute("d"), + expandedFolderPath, + ); + assert.equal(await rows.count(), 6); + await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); + await page.mouse.move(1000, 800); + await page.locator("body[data-ds-dark-theme]").waitFor(); + await shot(page, "sidebar-desktop-dark"); + await page.getByRole("button", { name: "View options", exact: true }).click(); + await page.getByRole("menuitem", { name: "In one list", exact: true }).click(); + assert.ok((await rows.count()) >= 10 && (await rows.count()) <= 11); + assert.equal( + await page.locator(`[data-row-key="${lastKey}"]`).getAttribute("aria-current"), + "page", + ); + await page.getByRole("button", { name: "Show 10 more", exact: true }).click(); + assert.ok((await rows.count()) >= 20 && (await rows.count()) <= 21); + await shot(page, "sidebar-flat-paged"); + } + assert.deepEqual(errors, []); + assert.equal( + host.requests.some((r) => + ["turn/start", "thread/start", "thread/archive"].includes(r.method), + ), + false, + ); + }, + ); +} diff --git a/packages/web-server/test/distribution/source-plugins.test.ts b/packages/web-server/test/distribution/source-plugins.test.ts index e15d2247a..90fb8e31b 100644 --- a/packages/web-server/test/distribution/source-plugins.test.ts +++ b/packages/web-server/test/distribution/source-plugins.test.ts @@ -48,6 +48,10 @@ it( readFileSync(join(output, "licenses/DSH-MIT.txt")), readFileSync(join(repoRoot, "apps/web-ui/LICENSE")), ); + assert.deepEqual( + readFileSync(join(output, "licenses/OPENAI-ICONS-MIT.txt")), + readFileSync(join(repoRoot, "apps/web-ui/OPENAI_ICONS_LICENSE.txt")), + ); assert.ok(readFileSync(join(output, "licenses/THIRD_PARTY_NOTICES.md")).length > 0); }, ); From a4f1d9a048638a33c9cbf3245c8673a55233bc58 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 10 Oct 2026 18:25:23 +0800 Subject: [PATCH 006/106] fix: simplify web sidebar and keep expanded folders blue --- apps/web-ui/OPENAI_ICONS_LICENSE.txt | 19 -- apps/web-ui/README.md | 2 - .../ui-codexhost/src/client/ImportIcon.tsx | 2 +- .../ui-primitives/src/icons/openai-glyphs.ts | 254 ------------------ .../client/ui-primitives/src/icons/openai.tsx | 55 ---- .../client/ui-primitives/src/index.ts | 1 - .../src/client/SettingsRoot.tsx | 2 +- .../ui-settings-general/src/client/chrome.tsx | 2 +- .../src/client/HeaderLeadingControls.tsx | 4 +- .../src/client/SidebarRoot.module.css | 7 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 6 +- .../src/client/rows/Rows.module.css | 167 +++++------- .../ui-workspace/src/client/rows/Rows.tsx | 143 +--------- .../client/rows/WorkspaceBrowser.module.css | 6 +- .../src/client/rows/WorkspaceBrowser.tsx | 39 +-- .../client/session-actions/ArchiveSession.tsx | 4 +- .../client/session-actions/ForkSession.tsx | 5 +- .../src/client/session-actions/PinSession.tsx | 4 +- .../client/session-actions/RenameSession.tsx | 2 +- docs/product/standalone-web.md | 4 +- packages/web-server/scripts/pack.ts | 2 - .../test/browser/ch-sidebar.test.ts | 101 ++++++- .../test/distribution/source-plugins.test.ts | 4 - packages/web-server/test/support/server.ts | 10 +- 24 files changed, 211 insertions(+), 634 deletions(-) delete mode 100644 apps/web-ui/OPENAI_ICONS_LICENSE.txt delete mode 100644 apps/web-ui/packages/client/ui-primitives/src/icons/openai-glyphs.ts delete mode 100644 apps/web-ui/packages/client/ui-primitives/src/icons/openai.tsx diff --git a/apps/web-ui/OPENAI_ICONS_LICENSE.txt b/apps/web-ui/OPENAI_ICONS_LICENSE.txt deleted file mode 100644 index b8ac8dc0f..000000000 --- a/apps/web-ui/OPENAI_ICONS_LICENSE.txt +++ /dev/null @@ -1,19 +0,0 @@ -Copyright 2025 OpenAI - -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/apps/web-ui/README.md b/apps/web-ui/README.md index 8cf9e5087..2dc0954d6 100644 --- a/apps/web-ui/README.md +++ b/apps/web-ui/README.md @@ -8,6 +8,4 @@ The `generated/remote.js` and matching declarations in selected packages are pin The upstream MIT license is retained in `LICENSE`, framework package licenses remain under `vendor/`, and upstream dependency notices are retained in `THIRD_PARTY_NOTICES.md`. Local import adaptations include private npm manifests, source exports, the selected source build, and removal of the upstream standalone Web Worker preview entry. The root LGPL license does not replace these notices. -Navigation icons additionally vendor a small SVG-only subset of OpenAI's public [`@openai/apps-sdk-ui` 0.2.2](https://github.com/openai/apps-sdk-ui) under MIT; the copyright and permission text are retained in [OPENAI_ICONS_LICENSE.txt](OPENAI_ICONS_LICENSE.txt). `ui-primitives/src/icons/openai-glyphs.ts` preserves the published geometry and fill rules, while `openai.tsx` adapts sizing, current color and decorative accessibility to existing controls. These are the public OpenAI glyphs, not a copy of Codex Desktop's private application or animation assets; individual private Desktop variants may differ. Harness brand assets still come from the installed plugins. - See [the Web feature document](../../docs/product/standalone-web.md) for runtime isolation, commands and supported behavior. diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/ImportIcon.tsx b/apps/web-ui/packages/client/ui-codexhost/src/client/ImportIcon.tsx index 0e45a88a2..5227d2977 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/ImportIcon.tsx +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/ImportIcon.tsx @@ -1,6 +1,6 @@ /** Sidebar entry icon for the import panel. */ import type { ReactNode } from "react"; -import { OpenAIDownloadIcon as IconDownloadOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import { IconDownloadOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client"; diff --git a/apps/web-ui/packages/client/ui-primitives/src/icons/openai-glyphs.ts b/apps/web-ui/packages/client/ui-primitives/src/icons/openai-glyphs.ts deleted file mode 100644 index 92a1f836a..000000000 --- a/apps/web-ui/packages/client/ui-primitives/src/icons/openai-glyphs.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** Generated SVG subset from @openai/apps-sdk-ui 0.2.2 (MIT), Copyright 2025 OpenAI. - * See openai.tsx and apps/web-ui/OPENAI_ICONS_LICENSE.txt. - * Geometry and fill rules are retained verbatim; no Desktop application code is included. - */ -export const openaiGlyphs = { - Archive: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M2.5 6C2.5 4.34315 3.84315 3 5.5 3H18.5C20.1569 3 21.5 4.34315 21.5 6V7C21.5 7.61337 21.3159 8.18374 21 8.6589V15.2413C21 16.0463 21 16.7106 20.9558 17.2518C20.9099 17.8139 20.8113 18.3306 20.564 18.816C20.1805 19.5686 19.5686 20.1805 18.816 20.564C18.3306 20.8113 17.8139 20.9099 17.2518 20.9558C16.7106 21 16.0463 21 15.2413 21H8.75868C7.95372 21 7.28936 21 6.74817 20.9558C6.18608 20.9099 5.66937 20.8113 5.18404 20.564C4.43139 20.1805 3.81947 19.5686 3.43597 18.816C3.18868 18.3306 3.09012 17.8139 3.04419 17.2518C2.99998 16.7106 2.99999 16.0463 3 15.2413L3 8.6589C2.68408 8.18374 2.5 7.61337 2.5 7V6ZM5.5 8H18.5C19.0523 8 19.5 7.55228 19.5 7V6C19.5 5.44772 19.0523 5 18.5 5H5.5C4.94772 5 4.5 5.44772 4.5 6V7C4.5 7.55228 4.94772 8 5.5 8ZM5 10V15.2C5 16.0566 5.00078 16.6389 5.03755 17.089C5.07337 17.5274 5.1383 17.7516 5.21799 17.908C5.40973 18.2843 5.7157 18.5903 6.09202 18.782C6.24842 18.8617 6.47262 18.9266 6.91104 18.9624C7.36113 18.9992 7.94342 19 8.8 19H15.2C16.0566 19 16.6389 18.9992 17.089 18.9624C17.5274 18.9266 17.7516 18.8617 17.908 18.782C18.2843 18.5903 18.5903 18.2843 18.782 17.908C18.8617 17.7516 18.9266 17.5274 18.9624 17.089C18.9992 16.6389 19 16.0566 19 15.2V10H5ZM15 13C15 13.5523 14.5523 14 14 14H10C9.44772 14 9 13.5523 9 13C9 12.4477 9.44772 12 10 12H14C14.5523 12 15 12.4477 15 13Z", - fill: "currentColor", - }, - ], - }, - Branch: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M7.75 18C7.75 17.3096 7.19036 16.75 6.5 16.75C5.80964 16.75 5.25 17.3096 5.25 18C5.25 18.6904 5.80964 19.25 6.5 19.25C7.19036 19.25 7.75 18.6904 7.75 18ZM7.75 6C7.75 5.30964 7.19036 4.75 6.5 4.75C5.80964 4.75 5.25 5.30964 5.25 6C5.25 6.69036 5.80964 7.25 6.5 7.25C7.19036 7.25 7.75 6.69036 7.75 6ZM18.75 6C18.75 5.30964 18.1904 4.75 17.5 4.75C16.8096 4.75 16.25 5.30964 16.25 6C16.25 6.69036 16.8096 7.25 17.5 7.25C18.1904 7.25 18.75 6.69036 18.75 6ZM20.75 6C20.75 7.44586 19.8054 8.66989 18.5 9.0918V10C18.5 11.6569 17.1569 13 15.5 13H8.5C7.94772 13 7.5 13.4477 7.5 14V14.9072C8.80556 15.329 9.75 16.554 9.75 18C9.75 19.7949 8.29493 21.25 6.5 21.25C4.70507 21.25 3.25 19.7949 3.25 18C3.25 16.554 4.19444 15.329 5.5 14.9072V9.0918C4.19459 8.66989 3.25 7.44586 3.25 6C3.25 4.20507 4.70507 2.75 6.5 2.75C8.29493 2.75 9.75 4.20507 9.75 6C9.75 7.44586 8.80541 8.66989 7.5 9.0918V11.1738C7.81306 11.0631 8.149 11 8.5 11H15.5C16.0523 11 16.5 10.5523 16.5 10V9.0918C15.1946 8.66989 14.25 7.44586 14.25 6C14.25 4.20507 15.7051 2.75 17.5 2.75C19.2949 2.75 20.75 4.20507 20.75 6Z", - fill: "currentColor", - }, - ], - }, - CaretRight: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M15 12 9 7v10l6-5Z", - }, - { - fillRule: "evenodd", - d: "M8.576 6.094a1 1 0 0 1 1.064.138l6 5a1 1 0 0 1 0 1.536l-6 5A1 1 0 0 1 8 17V7a1 1 0 0 1 .576-.906ZM10 9.135v5.73L13.438 12 10 9.135Z", - clipRule: "evenodd", - }, - ], - }, - ChevronUpDown: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M11.3415 4.74742C11.7185 4.41753 12.2815 4.41753 12.6585 4.74742L16.6585 8.24742C17.0741 8.61111 17.1162 9.24287 16.7526 9.6585C16.3889 10.0741 15.7571 10.1163 15.3415 9.75258L12 6.82877L8.65849 9.75258C8.24285 10.1163 7.61109 10.0741 7.24741 9.6585C6.88373 9.24287 6.92584 8.61111 7.34148 8.24742L11.3415 4.74742ZM7.24603 14.3578C7.60885 13.9414 8.24052 13.8979 8.65692 14.2608L12 17.1737L15.3431 14.2608C15.7594 13.8979 16.3911 13.9414 16.7539 14.3578C17.1167 14.7742 17.0733 15.4058 16.6569 15.7687L12.6569 19.2539C12.2804 19.582 11.7196 19.582 11.3431 19.2539L7.34305 15.7687C6.92666 15.4058 6.88322 14.7742 7.24603 14.3578Z", - fill: "currentColor", - }, - ], - }, - Clock: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM12 6C12.5523 6 13 6.44772 13 7V12C13 12.2652 12.8946 12.5196 12.7071 12.7071L10.2071 15.2071C9.81658 15.5976 9.18342 15.5976 8.79289 15.2071C8.40237 14.8166 8.40237 14.1834 8.79289 13.7929L11 11.5858V7C11 6.44772 11.4477 6 12 6Z", - fill: "currentColor", - }, - ], - }, - ComposeEditSquare: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M15.6729 3.91287C16.8918 2.69392 18.8682 2.69392 20.0871 3.91287C21.3061 5.13182 21.3061 7.10813 20.0871 8.32708L14.1499 14.2643C13.3849 15.0293 12.3925 15.5255 11.3215 15.6785L9.14142 15.9899C8.82983 16.0344 8.51546 15.9297 8.29289 15.7071C8.07033 15.4845 7.96554 15.1701 8.01005 14.8586L8.32149 12.6785C8.47449 11.6075 8.97072 10.615 9.7357 9.85006L15.6729 3.91287ZM18.6729 5.32708C18.235 4.88918 17.525 4.88918 17.0871 5.32708L11.1499 11.2643C10.6909 11.7233 10.3932 12.3187 10.3014 12.9613L10.1785 13.8215L11.0386 13.6986C11.6812 13.6068 12.2767 13.3091 12.7357 12.8501L18.6729 6.91287C19.1108 6.47497 19.1108 5.76499 18.6729 5.32708ZM11 3.99929C11.0004 4.55157 10.5531 4.99963 10.0008 5.00007C9.00227 5.00084 8.29769 5.00827 7.74651 5.06064C7.20685 5.11191 6.88488 5.20117 6.63803 5.32695C6.07354 5.61457 5.6146 6.07351 5.32698 6.63799C5.19279 6.90135 5.10062 7.24904 5.05118 7.8542C5.00078 8.47105 5 9.26336 5 10.4V13.6C5 14.7366 5.00078 15.5289 5.05118 16.1457C5.10062 16.7509 5.19279 17.0986 5.32698 17.3619C5.6146 17.9264 6.07354 18.3854 6.63803 18.673C6.90138 18.8072 7.24907 18.8993 7.85424 18.9488C8.47108 18.9992 9.26339 19 10.4 19H13.6C14.7366 19 15.5289 18.9992 16.1458 18.9488C16.7509 18.8993 17.0986 18.8072 17.362 18.673C17.9265 18.3854 18.3854 17.9264 18.673 17.3619C18.7988 17.1151 18.8881 16.7931 18.9393 16.2535C18.9917 15.7023 18.9991 14.9977 18.9999 13.9992C19.0003 13.4469 19.4484 12.9995 20.0007 13C20.553 13.0004 21.0003 13.4485 20.9999 14.0007C20.9991 14.9789 20.9932 15.7808 20.9304 16.4426C20.8664 17.116 20.7385 17.7136 20.455 18.2699C19.9757 19.2107 19.2108 19.9756 18.27 20.455C17.6777 20.7568 17.0375 20.8826 16.3086 20.9421C15.6008 21 14.7266 21 13.6428 21H10.3572C9.27339 21 8.39925 21 7.69138 20.9421C6.96253 20.8826 6.32234 20.7568 5.73005 20.455C4.78924 19.9756 4.02433 19.2107 3.54497 18.2699C3.24318 17.6776 3.11737 17.0374 3.05782 16.3086C2.99998 15.6007 2.99999 14.7266 3 13.6428V10.3572C2.99999 9.27337 2.99998 8.39922 3.05782 7.69134C3.11737 6.96249 3.24318 6.3223 3.54497 5.73001C4.02433 4.7892 4.78924 4.0243 5.73005 3.54493C6.28633 3.26149 6.88399 3.13358 7.55735 3.06961C8.21919 3.00673 9.02103 3.00083 9.99922 3.00007C10.5515 2.99964 10.9996 3.447 11 3.99929Z", - fill: "currentColor", - }, - ], - }, - DotsHorizontal: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M3 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm7 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm7 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z", - }, - ], - }, - Download: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M12 3C12.5523 3 13 3.44772 13 4V12.5858L15.2929 10.2929C15.6834 9.90237 16.3166 9.90237 16.7071 10.2929C17.0976 10.6834 17.0976 11.3166 16.7071 11.7071L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L7.29289 11.7071C6.90237 11.3166 6.90237 10.6834 7.29289 10.2929C7.68342 9.90237 8.31658 9.90237 8.70711 10.2929L11 12.5858V4C11 3.44772 11.4477 3 12 3ZM4 14C4.55229 14 5 14.4477 5 15V15.2C5 16.0566 5.00078 16.6389 5.03755 17.089C5.07337 17.5274 5.1383 17.7516 5.21799 17.908C5.40973 18.2843 5.7157 18.5903 6.09202 18.782C6.24842 18.8617 6.47262 18.9266 6.91104 18.9624C7.36113 18.9992 7.94342 19 8.8 19H15.2C16.0566 19 16.6389 18.9992 17.089 18.9624C17.5274 18.9266 17.7516 18.8617 17.908 18.782C18.2843 18.5903 18.5903 18.2843 18.782 17.908C18.8617 17.7516 18.9266 17.5274 18.9624 17.089C18.9992 16.6389 19 16.0566 19 15.2V15C19 14.4477 19.4477 14 20 14C20.5523 14 21 14.4477 21 15V15.2413C21 16.0463 21 16.7106 20.9558 17.2518C20.9099 17.8139 20.8113 18.3306 20.564 18.816C20.1805 19.5686 19.5686 20.1805 18.816 20.564C18.3306 20.8113 17.8139 20.9099 17.2518 20.9558C16.7106 21 16.0463 21 15.2413 21H8.75868C7.95372 21 7.28936 21 6.74817 20.9558C6.18608 20.9099 5.66937 20.8113 5.18404 20.564C4.43139 20.1805 3.81947 19.5686 3.43597 18.816C3.18868 18.3306 3.09012 17.8139 3.04419 17.2518C2.99998 16.7106 2.99999 16.0463 3 15.2413L3 15C3 14.4477 3.44772 14 4 14Z", - fill: "currentColor", - }, - ], - }, - Edit: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - d: "M16.793 2.793a3.121 3.121 0 1 1 4.414 4.414l-8.5 8.5A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l8.5-8.5Zm3 1.414a1.121 1.121 0 0 0-1.586 0L10 12.414V14h1.586l8.207-8.207a1.121 1.121 0 0 0 0-1.586ZM6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4a1 1 0 1 1 2 0v4a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h4a1 1 0 1 1 0 2H6Z", - clipRule: "evenodd", - }, - ], - }, - Eye: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M5.91456 7.59106C4.34202 9.04124 3.28878 10.7415 2.77064 11.6971C2.66597 11.8902 2.66597 12.1098 2.77064 12.3029C3.28878 13.2585 4.34202 14.9588 5.91456 16.4089C7.48207 17.8545 9.50584 19 12.0001 19C14.4944 19 16.5182 17.8545 18.0857 16.4089C19.6582 14.9588 20.7114 13.2585 21.2296 12.3029C21.3343 12.1098 21.3343 11.8902 21.2296 11.6971C20.7114 10.7415 19.6582 9.04124 18.0857 7.59105C16.5182 6.1455 14.4944 5 12.0001 5C9.50584 5 7.48207 6.1455 5.91456 7.59106ZM4.5587 6.1208C6.36071 4.45899 8.84593 3 12.0001 3C15.1543 3 17.6395 4.45899 19.4415 6.1208C21.2385 7.77798 22.4153 9.68799 22.9878 10.7438C23.4149 11.5315 23.4149 12.4685 22.9878 13.2562C22.4153 14.312 21.2385 16.222 19.4415 17.8792C17.6395 19.541 15.1543 21 12.0001 21C8.84593 21 6.36071 19.541 4.5587 17.8792C2.76171 16.222 1.5849 14.312 1.01244 13.2562C0.585372 12.4685 0.585371 11.5315 1.01244 10.7438C1.5849 9.688 2.76171 7.77798 4.5587 6.1208ZM12.0001 9.5C10.6194 9.5 9.50011 10.6193 9.50011 12C9.50011 13.3807 10.6194 14.5 12.0001 14.5C13.3808 14.5 14.5001 13.3807 14.5001 12C14.5001 10.6193 13.3808 9.5 12.0001 9.5ZM7.50011 12C7.50011 9.51472 9.51483 7.5 12.0001 7.5C14.4854 7.5 16.5001 9.51472 16.5001 12C16.5001 14.4853 14.4854 16.5 12.0001 16.5C9.51483 16.5 7.50011 14.4853 7.50011 12Z", - fill: "currentColor", - }, - ], - }, - EyeClosed: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - d: "M2.293 2.293a1 1 0 0 1 1.414 0l18 18a1 1 0 0 1-1.414 1.414l-2.514-2.514C16.204 20.24 14.274 21 12 21c-3.154 0-5.64-1.459-7.441-3.12-1.797-1.658-2.974-3.568-3.547-4.624a2.625 2.625 0 0 1 0-2.513c.578-1.065 1.78-3.017 3.624-4.693L2.293 3.707a1 1 0 0 1 0-1.414Zm3.759 5.173c-1.645 1.473-2.745 3.241-3.281 4.23a.625.625 0 0 0 0 .607c.518.955 1.57 2.656 3.143 4.106C7.482 17.855 9.506 19 12 19c1.65 0 3.09-.5 4.33-1.256l-1.934-1.934A4.502 4.502 0 0 1 8.19 9.604L6.052 7.466Zm3.62 3.62 3.242 3.242a2.5 2.5 0 0 1-3.242-3.242Zm.551-5.886c.56-.128 1.152-.2 1.777-.2 2.494 0 4.518 1.146 6.086 2.591 1.572 1.45 2.625 3.15 3.144 4.106a.627.627 0 0 1-.002.608 17.316 17.316 0 0 1-1.344 2.095 1 1 0 0 0 1.6 1.2 19.327 19.327 0 0 0 1.503-2.342 2.627 2.627 0 0 0 0-2.514c-.572-1.056-1.749-2.966-3.546-4.623C17.64 4.459 15.154 3 12 3c-.779 0-1.52.09-2.223.25a1 1 0 0 0 .446 1.95Z", - clipRule: "evenodd", - }, - ], - }, - Folder: { - viewBox: "0 0 20 20", - paths: [ - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M1.66669 5C1.66669 3.61929 2.78598 2.5 4.16669 2.5H7.643C8.30604 2.5 8.94192 2.76339 9.41077 3.23223L10.1011 3.92259C10.2574 4.07887 10.4694 4.16667 10.6904 4.16667H15.8334C17.2141 4.16667 18.3334 5.28595 18.3334 6.66667V15C18.3334 16.3807 17.2141 17.5 15.8334 17.5H4.16669C2.78598 17.5 1.66669 16.3807 1.66669 15V5ZM4.16669 4.16667C3.70645 4.16667 3.33335 4.53976 3.33335 5V8.33333H16.6667V6.66667C16.6667 6.20643 16.2936 5.83333 15.8334 5.83333H10.6904C10.0273 5.83333 9.39145 5.56994 8.92261 5.1011L8.23225 4.41074C8.07597 4.25446 7.86401 4.16667 7.643 4.16667H4.16669ZM16.6667 10H3.33335V15C3.33335 15.4602 3.70645 15.8333 4.16669 15.8333H15.8334C16.2936 15.8333 16.6667 15.4602 16.6667 15V10Z", - }, - ], - }, - FolderPlus: { - viewBox: "0 0 18 18", - paths: [ - { - d: "M15 11.25C15 10.8358 14.6642 10.5 14.25 10.5C13.8358 10.5 13.5 10.8358 13.5 11.25V12.75H12C11.5858 12.75 11.25 13.0858 11.25 13.5C11.25 13.9142 11.5858 14.25 12 14.25H13.5V15.75C13.5 16.1642 13.8358 16.5 14.25 16.5C14.6642 16.5 15 16.1642 15 15.75V14.25H16.5C16.9142 14.25 17.25 13.9142 17.25 13.5C17.25 13.0858 16.9142 12.75 16.5 12.75H15V11.25Z", - }, - { - d: "M16.4669 6.56113C16.5 6.96703 16.5 7.46529 16.5 8.06901V8.25C16.5 8.66421 16.1642 9 15.75 9L3 9V11.4C3 12.0424 3.00058 12.4792 3.02816 12.8167C3.05503 13.1455 3.10372 13.3137 3.16349 13.431C3.3073 13.7132 3.53677 13.9427 3.81902 14.0865C3.93632 14.1463 4.10447 14.195 4.43328 14.2218C4.77085 14.2494 5.20757 14.25 5.85 14.25H9C9.41421 14.25 9.75 14.5858 9.75 15C9.75 15.4142 9.41421 15.75 9 15.75H5.81901C5.21529 15.75 4.71702 15.75 4.31113 15.7169C3.88956 15.6824 3.50203 15.6085 3.13803 15.423C2.57354 15.1354 2.1146 14.6765 1.82698 14.112C1.64151 13.748 1.56759 13.3604 1.53315 12.9389C1.49998 12.533 1.49999 12.0347 1.5 11.431V6.56903C1.49999 5.96531 1.49998 5.46703 1.53315 5.06113C1.56759 4.63956 1.64151 4.25203 1.82698 3.88803C2.1146 3.32355 2.57354 2.8646 3.13803 2.57698C3.50203 2.39152 3.88956 2.31759 4.31113 2.28315C4.71703 2.24999 5.2153 2.24999 5.81903 2.25L6.45692 2.25C6.56639 2.24999 6.64898 2.24998 6.7306 2.25441C7.41309 2.29145 8.06248 2.56044 8.57126 3.01684C8.63212 3.07143 8.69047 3.12979 8.76789 3.20723L8.78033 3.21967C8.87548 3.31482 8.90361 3.34259 8.93036 3.36659C9.18475 3.59479 9.50945 3.72928 9.85069 3.7478C9.88658 3.74975 9.92611 3.75 10.0607 3.75L12.181 3.75C12.7847 3.74999 13.283 3.74999 13.6889 3.78315C14.1104 3.81759 14.498 3.89152 14.862 4.07698C15.4265 4.3646 15.8854 4.82355 16.173 5.38803C16.3585 5.75203 16.4324 6.13956 16.4669 6.56113ZM7.56964 4.13342C7.31525 3.90522 6.99056 3.77073 6.64931 3.75221C6.61342 3.75026 6.5739 3.75 6.43934 3.75H5.85C5.20757 3.75 4.77085 3.75059 4.43328 3.77817C4.10447 3.80503 3.93632 3.85373 3.81902 3.91349C3.53677 4.0573 3.3073 4.28677 3.16349 4.56902C3.10372 4.68632 3.05503 4.85447 3.02816 5.18328C3.00058 5.52085 3 5.95757 3 6.6V7.5H14.9987C14.9963 7.16586 14.9898 6.90348 14.9718 6.68328C14.945 6.35447 14.8963 6.18632 14.8365 6.06902C14.6927 5.78678 14.4632 5.5573 14.181 5.41349C14.0637 5.35373 13.8955 5.30503 13.5667 5.27817C13.2292 5.25059 12.7924 5.25 12.15 5.25L10.0431 5.25001C9.93357 5.25002 9.85104 5.25003 9.7694 5.2456C9.08691 5.20855 8.43753 4.93957 7.92874 4.48317C7.86788 4.42857 7.80953 4.37021 7.73211 4.29277L7.71967 4.28033C7.62453 4.18519 7.5964 4.15742 7.56964 4.13342Z", - }, - ], - }, - Folders: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M13.0183 4.0353C12.9 4.00657 12.7623 4 12.1573 4H10.6C9.95344 4 9.52866 4.00078 9.20364 4.02733C8.89029 4.05293 8.7587 4.09742 8.68251 4.13624C8.44731 4.25608 8.25609 4.44731 8.13624 4.68251C8.09743 4.75869 8.05294 4.89029 8.02733 5.20364C8.00972 5.41922 8.00345 5.67869 8.00122 6.01765C8.16945 6.03065 8.33091 6.0531 8.49017 6.09177C8.82524 6.17313 9.14522 6.30729 9.43812 6.48922C9.77107 6.69603 10.0467 6.97686 10.3749 7.3113C10.3957 7.33248 10.4167 7.35387 10.4379 7.37547L10.544 7.48343C10.9681 7.91494 11.0693 8.00848 11.1727 8.07271C11.2854 8.14268 11.4084 8.19428 11.5373 8.22557C11.6556 8.2543 11.7932 8.26087 12.3983 8.26087L13.4394 8.26087C14.0359 8.26085 14.543 8.26084 14.9592 8.29484C15.3963 8.33055 15.8204 8.40868 16.2255 8.6151C16.837 8.92669 17.3342 9.42387 17.6458 10.0354C17.8522 10.4405 17.9303 10.8646 17.966 11.3016C18 11.7179 18 12.225 18 12.8214V15H18.4C19.0466 15 19.4713 14.9992 19.7964 14.9727C20.1097 14.9471 20.2413 14.9026 20.3175 14.8638C20.5527 14.7439 20.7439 14.5527 20.8638 14.3175C20.9026 14.2413 20.9471 14.1097 20.9727 13.7964C20.9992 13.4713 21 13.0466 21 12.4V8.86087C21 8.21431 20.9992 7.78953 20.9727 7.46451C20.9471 7.15115 20.9026 7.01956 20.8638 6.94338C20.7439 6.70818 20.5527 6.51695 20.3175 6.39711C20.2413 6.35829 20.1097 6.3138 19.7964 6.2882C19.4713 6.26165 19.0466 6.26087 18.4 6.26087H17.3983C17.368 6.26087 17.338 6.26089 17.3084 6.26092C16.8398 6.26128 16.4463 6.26159 16.0654 6.1691C15.7303 6.08774 15.4103 5.95358 15.1174 5.77165C14.7845 5.56484 14.5089 5.28401 14.1807 4.94956C14.1599 4.92838 14.1389 4.907 14.1177 4.8854L14.0115 4.77744C13.5874 4.34593 13.4863 4.25239 13.3828 4.18816C13.2702 4.11819 13.1471 4.06659 13.0183 4.0353ZM17.9988 17H18.4394C19.0359 17 19.543 17 19.9592 16.966C20.3963 16.9303 20.8204 16.8522 21.2255 16.6458C21.837 16.3342 22.3342 15.837 22.6458 15.2255C22.8522 14.8204 22.9303 14.3963 22.966 13.9592C23 13.543 23 13.0359 23 12.4394V8.82142C23 8.22498 23 7.71785 22.966 7.30164C22.9303 6.86461 22.8522 6.44052 22.6458 6.0354C22.3342 5.42387 21.837 4.92669 21.2255 4.6151C20.8204 4.40868 20.3963 4.33055 19.9592 4.29484C19.543 4.26084 19.0359 4.26085 18.4394 4.26087L17.3983 4.26087C16.7932 4.26087 16.6556 4.2543 16.5373 4.22557C16.4084 4.19428 16.2854 4.14268 16.1727 4.07271C16.0693 4.00848 15.9681 3.91494 15.544 3.48343L15.4379 3.37547C15.4167 3.35387 15.3957 3.33248 15.3749 3.31131C15.0467 2.97686 14.7711 2.69603 14.4381 2.48922C14.1452 2.30729 13.8252 2.17313 13.4902 2.09177C13.1093 1.99928 12.7158 1.99959 12.2472 1.99995C12.2175 1.99998 12.1876 2 12.1573 2L10.5606 2C9.96412 1.99998 9.45699 1.99997 9.04077 2.03398C8.60374 2.06968 8.17965 2.14781 7.77453 2.35423C7.16301 2.66582 6.66582 3.163 6.35423 3.77453C6.14781 4.17965 6.06968 4.60374 6.03398 5.04077C6.01118 5.31981 6.00367 5.63972 6.0012 6L5.56057 6C4.96412 5.99998 4.45699 5.99997 4.04077 6.03398C3.60374 6.06968 3.17965 6.14781 2.77453 6.35423C2.16301 6.66582 1.66582 7.163 1.35423 7.77453C1.14781 8.17965 1.06968 8.60374 1.03398 9.04077C0.999971 9.45699 0.999984 9.96412 1 10.5606V16.4394C0.999984 17.0359 0.999971 17.543 1.03398 17.9592C1.06968 18.3963 1.14781 18.8204 1.35423 19.2255C1.66582 19.837 2.16301 20.3342 2.77453 20.6458C3.17965 20.8522 3.60374 20.9303 4.04077 20.966C4.45699 21 4.96411 21 5.56055 21H13.4395C14.0359 21 14.543 21 14.9592 20.966C15.3963 20.9303 15.8204 20.8522 16.2255 20.6458C16.837 20.3342 17.3342 19.837 17.6458 19.2255C17.8522 18.8204 17.9303 18.3963 17.966 17.9592C17.9888 17.6802 17.9963 17.3603 17.9988 17ZM16 12.8609C16 12.2143 15.9992 11.7895 15.9727 11.4645C15.9471 11.1512 15.9026 11.0196 15.8638 10.9434C15.7439 10.7082 15.5527 10.517 15.3175 10.3971C15.2413 10.3583 15.1097 10.3138 14.7964 10.2882C14.4713 10.2616 14.0466 10.2609 13.4 10.2609H12.3983C12.368 10.2609 12.338 10.2609 12.3084 10.2609C11.8398 10.2613 11.4463 10.2616 11.0654 10.1691C10.7303 10.0877 10.4103 9.95358 10.1174 9.77165C9.78448 9.56484 9.50888 9.28401 9.18066 8.94956C9.15988 8.92839 9.13889 8.907 9.11766 8.8854L9.01155 8.77744C8.58741 8.34593 8.48626 8.25239 8.38285 8.18816C8.2702 8.11819 8.14713 8.06659 8.01825 8.0353C7.89996 8.00657 7.76234 8 7.15729 8H5.6C4.95344 8 4.52866 8.00078 4.20364 8.02733C3.89029 8.05293 3.7587 8.09742 3.68251 8.13624C3.44731 8.25608 3.25608 8.44731 3.13624 8.68251C3.09743 8.75869 3.05294 8.89028 3.02733 9.20364C3.00078 9.52866 3 9.95344 3 10.6V16.4C3 17.0466 3.00078 17.4713 3.02733 17.7964C3.05294 18.1097 3.09743 18.2413 3.13624 18.3175C3.25608 18.5527 3.44731 18.7439 3.68251 18.8638C3.7587 18.9026 3.89029 18.9471 4.20364 18.9727C4.52866 18.9992 4.95344 19 5.6 19H13.4C14.0466 19 14.4713 18.9992 14.7964 18.9727C15.1097 18.9471 15.2413 18.9026 15.3175 18.8638C15.5527 18.7439 15.7439 18.5527 15.8638 18.3175C15.9026 18.2413 15.9471 18.1097 15.9727 17.7964C15.9992 17.4713 16 17.0466 16 16.4V12.8609Z", - fill: "currentColor", - }, - ], - }, - Menu: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M3 8a1 1 0 0 1 1-1h16a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 8a1 1 0 0 1 1-1h10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Z", - }, - ], - }, - Pin: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M12.8636 3.26026C13.9444 1.74705 16.1254 1.56655 17.4403 2.88148L21.1185 6.55971C22.4335 7.87464 22.2529 10.0556 20.7397 11.1364L16.4786 14.1801C16.1638 14.405 16 14.7305 16 15V17.5C16 18.9069 15.0409 19.9513 13.976 20.4104C12.9046 20.8724 11.4792 20.8468 10.4568 19.8243L8.02332 17.3909L3.70711 21.7071C3.31658 22.0976 2.68342 22.0976 2.29289 21.7071C1.90237 21.3166 1.90237 20.6834 2.29289 20.2929L6.60911 15.9767L4.17567 13.5432C3.1532 12.5208 3.12762 11.0954 3.58957 10.024C4.04871 8.95908 5.09306 8 6.5 8H9C9.26948 8 9.59505 7.8362 9.81994 7.52136L12.8636 3.26026ZM8.73001 15.2692C8.73015 15.2693 8.73029 15.2694 8.73043 15.2696C8.73057 15.2697 8.73071 15.2698 8.73084 15.27L11.871 18.4101C12.1769 18.716 12.6696 18.7957 13.1842 18.5739C13.7052 18.3492 14 17.9208 14 17.5V15C14 13.9717 14.5749 13.0821 15.3162 12.5526L19.5773 9.50895C20.0848 9.14643 20.1453 8.41495 19.7043 7.97392L16.0261 4.29569C15.5851 3.85467 14.8536 3.9152 14.491 4.42273L11.4474 8.68383C10.9179 9.42507 10.0283 10 9 10H6.5C6.07925 10 5.65079 10.2948 5.42615 10.8158C5.20431 11.3304 5.28397 11.8231 5.58988 12.129L8.73001 15.2692Z", - fill: "currentColor", - }, - ], - }, - PinFilled: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M12.8636 3.26029C13.9444 1.74708 16.1254 1.56658 17.4403 2.88151L21.1185 6.55974C22.4335 7.87467 22.2529 10.0556 20.7397 11.1364L16.4786 14.1801C16.1638 14.405 16 14.7306 16 15V17.5C16 18.907 15.0409 19.9513 13.976 20.4105C12.9046 20.8724 11.4792 20.8468 10.4568 19.8244L8.02332 17.3909L3.70711 21.7071C3.31658 22.0977 2.68342 22.0977 2.29289 21.7071C1.90237 21.3166 1.90237 20.6835 2.29289 20.2929L6.60911 15.9767L4.17567 13.5433C3.1532 12.5208 3.12762 11.0955 3.58957 10.024C4.04871 8.95911 5.09306 8.00003 6.5 8.00003H9C9.26948 8.00003 9.59505 7.83624 9.81994 7.52139L12.8636 3.26029Z", - fill: "currentColor", - }, - ], - }, - Plus: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - d: "M12 5a1 1 0 0 1 1 1v5h5a1 1 0 1 1 0 2h-5v5a1 1 0 1 1-2 0v-5H6a1 1 0 1 1 0-2h5V6a1 1 0 0 1 1-1Z", - clipRule: "evenodd", - }, - ], - }, - Search: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M10.875 4.5C7.35418 4.5 4.5 7.35418 4.5 10.875C4.5 14.3958 7.35418 17.25 10.875 17.25C14.3958 17.25 17.25 14.3958 17.25 10.875C17.25 7.35418 14.3958 4.5 10.875 4.5ZM2.5 10.875C2.5 6.24962 6.24962 2.5 10.875 2.5C15.5004 2.5 19.25 6.24962 19.25 10.875C19.25 12.8273 18.582 14.6236 17.462 16.0478L21.2071 19.7929C21.5976 20.1834 21.5976 20.8166 21.2071 21.2071C20.8166 21.5976 20.1834 21.5976 19.7929 21.2071L16.0478 17.462C14.6236 18.582 12.8273 19.25 10.875 19.25C6.24962 19.25 2.5 15.5004 2.5 10.875Z", - fill: "currentColor", - }, - ], - }, - SettingsCog: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M11.6099 3.75C11.2549 3.75 10.9266 3.93813 10.7471 4.24434L9.99036 5.53548C9.45636 6.44657 8.48265 7.00978 7.42663 7.01839L5.94021 7.03051C5.58531 7.0334 5.25853 7.22419 5.08156 7.53183L4.6852 8.22082C4.50905 8.52702 4.50752 8.90345 4.68118 9.21107L5.42305 10.5252C5.93977 11.4405 5.93977 12.5595 5.42305 13.4748L4.68118 14.7889C4.50752 15.0966 4.50905 15.473 4.6852 15.7792L5.08156 16.4682C5.25853 16.7758 5.58531 16.9666 5.94021 16.9695L7.42665 16.9816C8.48266 16.9902 9.45637 17.5535 9.99037 18.4645L10.7471 19.7557C10.9266 20.0619 11.2549 20.25 11.6099 20.25H12.3901C12.7451 20.25 13.0734 20.0619 13.2529 19.7557L14.0096 18.4645C14.5436 17.5535 15.5173 16.9902 16.5734 16.9816L18.0599 16.9695C18.4148 16.9666 18.7416 16.7758 18.9185 16.4682L19.3149 15.7792C19.491 15.473 19.4926 15.0966 19.3189 14.7889L18.577 13.4748C18.0603 12.5595 18.0603 11.4405 18.577 10.5252L19.3189 9.21107C19.4926 8.90345 19.491 8.52702 19.3149 8.22082L18.9185 7.53183C18.7416 7.22419 18.4148 7.0334 18.0599 7.03051L16.5734 7.01839C15.5174 7.00978 14.5437 6.44657 14.0096 5.53548L13.2529 4.24434C13.0734 3.93813 12.7451 3.75 12.3901 3.75H11.6099ZM9.02167 3.23301C9.56009 2.31439 10.5451 1.75 11.6099 1.75H12.3901C13.4549 1.75 14.4399 2.31439 14.9783 3.23301L15.7351 4.52415C15.9131 4.82785 16.2377 5.01558 16.5897 5.01845L18.0762 5.03058C19.1409 5.03926 20.1212 5.61161 20.6521 6.53452L21.0485 7.22352C21.577 8.14213 21.5815 9.27141 21.0605 10.1943L20.3187 11.5084C20.1464 11.8135 20.1464 12.1865 20.3187 12.4916L21.0605 13.8057C21.5815 14.7286 21.577 15.8579 21.0485 16.7765L20.6521 17.4655C20.1212 18.3884 19.1409 18.9608 18.0762 18.9694L16.5897 18.9816C16.2377 18.9844 15.9131 19.1722 15.7351 19.4759L14.9783 20.767C14.4399 21.6856 13.4549 22.25 12.3901 22.25H11.6099C10.5451 22.25 9.56009 21.6856 9.02167 20.767L8.26491 19.4759C8.08691 19.1722 7.76234 18.9844 7.41034 18.9816L5.9239 18.9694C4.8592 18.9608 3.87888 18.3884 3.34795 17.4655L2.95159 16.7765C2.42314 15.8579 2.41856 14.7286 2.93954 13.8057L3.68141 12.4916C3.85365 12.1865 3.85365 11.8135 3.68141 11.5084L2.93954 10.1943C2.41856 9.27141 2.42314 8.14213 2.95159 7.22352L3.34795 6.53453C3.87888 5.61162 4.8592 5.03926 5.9239 5.03058L7.41032 5.01845C7.76233 5.01558 8.0869 4.82785 8.2649 4.52415L9.02167 3.23301Z", - fill: "currentColor", - }, - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8285 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8285 10.5 12 10.5ZM8.50004 12C8.50004 10.067 10.067 8.5 12 8.5C13.933 8.5 15.5 10.067 15.5 12C15.5 13.933 13.933 15.5 12 15.5C10.067 15.5 8.50004 13.933 8.50004 12Z", - fill: "currentColor", - }, - ], - }, - SettingsSlider: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M14.5 5C13.3954 5 12.5 5.89543 12.5 7C12.5 8.10457 13.3954 9 14.5 9C15.6046 9 16.5 8.10457 16.5 7C16.5 5.89543 15.6046 5 14.5 5ZM10.626 6C11.0701 4.27477 12.6362 3 14.5 3C16.3638 3 17.9299 4.27477 18.374 6H20C20.5523 6 21 6.44772 21 7C21 7.55228 20.5523 8 20 8H18.374C17.9299 9.72523 16.3638 11 14.5 11C12.6362 11 11.0701 9.72523 10.626 8H4C3.44772 8 3 7.55228 3 7C3 6.44772 3.44772 6 4 6H10.626ZM9.5 15C8.39543 15 7.5 15.8954 7.5 17C7.5 18.1046 8.39543 19 9.5 19C10.6046 19 11.5 18.1046 11.5 17C11.5 15.8954 10.6046 15 9.5 15ZM5.62602 16C6.07006 14.2748 7.63616 13 9.5 13C11.3638 13 12.9299 14.2748 13.374 16H20C20.5523 16 21 16.4477 21 17C21 17.5523 20.5523 18 20 18H13.374C12.9299 19.7252 11.3638 21 9.5 21C7.63616 21 6.07006 19.7252 5.62602 18H4C3.44772 18 3 17.5523 3 17C3 16.4477 3.44772 16 4 16H5.62602Z", - fill: "currentColor", - }, - ], - }, - SidebarLeft: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - d: "M6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2V5H6Zm4 0v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-8ZM3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Z", - clipRule: "evenodd", - }, - ], - }, - Trash: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - clipRule: "evenodd", - d: "M10.5555 4C10.099 4 9.70052 4.30906 9.58693 4.75114L9.29382 5.8919H14.715L14.4219 4.75114C14.3083 4.30906 13.9098 4 13.4533 4H10.5555ZM16.7799 5.8919L16.3589 4.25342C16.0182 2.92719 14.8226 2 13.4533 2H10.5555C9.18616 2 7.99062 2.92719 7.64985 4.25342L7.22886 5.8919H4C3.44772 5.8919 3 6.33961 3 6.8919C3 7.44418 3.44772 7.8919 4 7.8919H4.10069L5.31544 19.3172C5.47763 20.8427 6.76455 22 8.29863 22H15.7014C17.2354 22 18.5224 20.8427 18.6846 19.3172L19.8993 7.8919H20C20.5523 7.8919 21 7.44418 21 6.8919C21 6.33961 20.5523 5.8919 20 5.8919H16.7799ZM17.888 7.8919H6.11196L7.30423 19.1057C7.3583 19.6142 7.78727 20 8.29863 20H15.7014C16.2127 20 16.6417 19.6142 16.6958 19.1057L17.888 7.8919ZM10 10C10.5523 10 11 10.4477 11 11V16C11 16.5523 10.5523 17 10 17C9.44772 17 9 16.5523 9 16V11C9 10.4477 9.44772 10 10 10ZM14 10C14.5523 10 15 10.4477 15 11V16C15 16.5523 14.5523 17 14 17C13.4477 17 13 16.5523 13 16V11C13 10.4477 13.4477 10 14 10Z", - }, - ], - }, - Unarchive: { - viewBox: "0 0 24 24", - paths: [ - { - d: "M18.4554 3.91136C18.3125 3.37789 17.7641 3.06131 17.2307 3.20425L4.19066 6.69831C3.65719 6.84125 3.34061 7.38959 3.48355 7.92305L3.67766 8.6475C3.82061 9.18097 4.36894 9.49755 4.90241 9.35461L17.9424 5.86055C18.4759 5.71761 18.7925 5.16927 18.6495 4.6358L18.4554 3.91136ZM16.713 1.2724C18.3134 0.843575 19.9584 1.79332 20.3873 3.39372L20.5814 4.11816C21.0102 5.71856 20.0604 7.36357 18.46 7.7924L5.42005 11.2865C5.28009 11.324 5.13978 11.3509 4.99999 11.3678V17C4.99999 18.1046 5.89542 19 6.99999 19H17C18.1046 19 19 18.1046 19 17V10C19 9.44774 19.4477 9.00002 20 9.00002C20.5523 9.00002 21 9.44774 21 10V17C21 19.2092 19.2091 21 17 21H6.99999C4.79085 21 2.99999 19.2092 2.99999 17V10.8984C2.40441 10.5087 1.94453 9.90676 1.74581 9.16514L1.5517 8.44069C1.12287 6.84029 2.07262 5.19528 3.67302 4.76646L16.713 1.2724ZM8.99999 13C8.99999 12.4477 9.4477 12 9.99999 12H14C14.5523 12 15 12.4477 15 13C15 13.5523 14.5523 14 14 14H9.99999C9.4477 14 8.99999 13.5523 8.99999 13Z", - fill: "currentColor", - }, - ], - }, - X: { - viewBox: "0 0 24 24", - paths: [ - { - fillRule: "evenodd", - d: "M5.636 5.636a1 1 0 0 1 1.414 0l4.95 4.95 4.95-4.95a1 1 0 0 1 1.414 1.414L13.414 12l4.95 4.95a1 1 0 0 1-1.414 1.414L12 13.414l-4.95 4.95a1 1 0 0 1-1.414-1.414l4.95-4.95-4.95-4.95a1 1 0 0 1 0-1.414Z", - clipRule: "evenodd", - }, - ], - }, -} as const; diff --git a/apps/web-ui/packages/client/ui-primitives/src/icons/openai.tsx b/apps/web-ui/packages/client/ui-primitives/src/icons/openai.tsx deleted file mode 100644 index 1175f33fc..000000000 --- a/apps/web-ui/packages/client/ui-primitives/src/icons/openai.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Public OpenAI SVG artwork for the Codex-style navigation surfaces. - * Source: @openai/apps-sdk-ui 0.2.2, MIT, Copyright 2025 OpenAI. - * License: apps/web-ui/OPENAI_ICONS_LICENSE.txt. - * This is the public icon family, not an extraction of private Desktop assets. - */ -import type { IconProps } from "./props.ts"; -import { openaiGlyphs } from "./openai-glyphs.ts"; - -function icon(name: keyof typeof openaiGlyphs) { - const glyph = openaiGlyphs[name]; - const paths = glyph.paths.map((path, index) => ); - return function OpenAIIcon({ size = 16, className }: IconProps) { - return ( - - ); - }; -} - -export const OpenAIArchiveIcon = icon("Archive"); -export const OpenAIBranchIcon = icon("Branch"); -export const OpenAICaretRightIcon = icon("CaretRight"); -export const OpenAISortIcon = icon("ChevronUpDown"); -export const OpenAIClockIcon = icon("Clock"); -export const OpenAINewChatIcon = icon("ComposeEditSquare"); -export const OpenAIMoreIcon = icon("DotsHorizontal"); -export const OpenAIDownloadIcon = icon("Download"); -export const OpenAIEditIcon = icon("Edit"); -export const OpenAIEyeIcon = icon("Eye"); -export const OpenAIEyeOffIcon = icon("EyeClosed"); -export const OpenAIFolderIcon = icon("Folder"); -export const OpenAIFolderAddIcon = icon("FolderPlus"); -export const OpenAIFoldersIcon = icon("Folders"); -export const OpenAIListIcon = icon("Menu"); -export const OpenAIPinIcon = icon("Pin"); -export const OpenAIPinFilledIcon = icon("PinFilled"); -export const OpenAIPlusIcon = icon("Plus"); -export const OpenAISearchIcon = icon("Search"); -export const OpenAISettingsIcon = icon("SettingsCog"); -export const OpenAIViewOptionsIcon = icon("SettingsSlider"); -export const OpenAISidebarIcon = icon("SidebarLeft"); -export const OpenAITrashIcon = icon("Trash"); -export const OpenAIUnarchiveIcon = icon("Unarchive"); -export const OpenAICloseIcon = icon("X"); diff --git a/apps/web-ui/packages/client/ui-primitives/src/index.ts b/apps/web-ui/packages/client/ui-primitives/src/index.ts index 36b4eddd2..feafaa3e0 100644 --- a/apps/web-ui/packages/client/ui-primitives/src/index.ts +++ b/apps/web-ui/packages/client/ui-primitives/src/index.ts @@ -152,7 +152,6 @@ export type { export { extractMarkdownPlainText } from "./markdown/plain-text.ts"; export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from "./markdown/plain-text.ts"; export * from "./icons/index.tsx"; -export * from "./icons/openai.tsx"; export { PluginArtworkTerminal, PluginArtworkLoop, diff --git a/apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index fd7cf58dd..1e23b3a47 100644 --- a/apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/apps/web-ui/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -22,7 +22,7 @@ import { IconCloseOutlineRegular, IconDataOutlineMedium, IconPersonalizationOutlineMedium, - OpenAISettingsIcon as IconSettingsOutlineMedium, + IconSettingsOutlineMedium, IconUserOutlineMedium, } from "@deepseek-ai/dsh-client-ui-primitives"; import type { ConnectionIndicatorState } from "@deepseek-ai/dsh-client-ui-primitives"; diff --git a/apps/web-ui/packages/client/ui-settings-general/src/client/chrome.tsx b/apps/web-ui/packages/client/ui-settings-general/src/client/chrome.tsx index 33532e9bf..90c6085d3 100644 --- a/apps/web-ui/packages/client/ui-settings-general/src/client/chrome.tsx +++ b/apps/web-ui/packages/client/ui-settings-general/src/client/chrome.tsx @@ -4,7 +4,7 @@ * The shell renders the surrounding chrome (button, nav heading row) and * reads each entry's `label` option for aria text. */ -import { OpenAISettingsIcon as IconSettingsOutlineMedium } from "@deepseek-ai/dsh-client-ui-primitives"; +import { IconSettingsOutlineMedium } from "@deepseek-ai/dsh-client-ui-primitives"; import type { PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; import css from "./chrome.module.css"; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index 0415668fe..7467ceb25 100644 --- a/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,7 +1,7 @@ /** Window-chrome controls for the fully hidden sidebar (frame shell.leading seat). */ import { - OpenAINewChatIcon as IconNewChatOutlineRegular, - OpenAISidebarIcon as IconPanelLeftOutlineRegular, + IconNewChatOutlineRegular, + IconPanelLeftOutlineRegular, Tooltip, } from "@deepseek-ai/dsh-client-ui-primitives"; import type { InjectFace, PropsLocale, PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 2e5d2792c..be85e549a 100644 --- a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -411,10 +411,10 @@ margin: 0 0 2px; box-sizing: border-box; border: none; - border-radius: 6px; + border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); - font-size: 13px; + font-size: 14px; font-weight: 500; line-height: 22px; cursor: pointer; @@ -530,11 +530,10 @@ padding: 7px 8px; box-sizing: border-box; border: none; - border-radius: 6px; + border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font: inherit; - font-size: 13px; line-height: 22px; text-align: left; cursor: pointer; diff --git a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 019d3c870..f01bd55aa 100644 --- a/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/apps/web-ui/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -20,9 +20,9 @@ import { useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { FishLogo, - OpenAINewChatIcon as IconNewChatOutlineMedium, - OpenAINewChatIcon as IconNewChatOutlineRegular, - OpenAISidebarIcon as IconPanelLeftOutlineRegular, + IconNewChatOutlineMedium, + IconNewChatOutlineRegular, + IconPanelLeftOutlineRegular, isDarwinDesktop, ShortcutKeys, Tooltip, diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css index f097378ac..de56107c6 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,9 +1,11 @@ .projectRow, .sessionRow { + --dsh-row-surface: var(--dsw-specific-sidebar-fill); + position: relative; display: flex; align-items: center; gap: 6px; - border-radius: 6px; + border-radius: var(--dsw-radius-md); padding: 0 8px; padding-inline-start: calc(8px + var(--dsh-workspace-indent, 0px)); cursor: pointer; @@ -12,17 +14,31 @@ color: var(--dsw-alias-label-primary); } -.projectRow:hover, -.sessionRow:hover { - background: color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent); +.projectRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen), +.sessionRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) { + --dsh-row-surface: color-mix( + in srgb, + var(--dsw-alias-label-primary) 4%, + var(--dsw-specific-sidebar-fill) + ); + background: var(--dsh-row-surface); } .sessionRow.selected { - background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent); + --dsh-row-surface: color-mix( + in srgb, + var(--dsw-alias-label-primary) 6%, + var(--dsw-specific-sidebar-fill) + ); + background: var(--dsh-row-surface); } -.sessionRow.selected:hover { - background: color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent); +.sessionRow.selected:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) { + --dsh-row-surface: color-mix( + in srgb, + var(--dsw-alias-label-primary) 8%, + var(--dsw-specific-sidebar-fill) + ); } .projectRow:focus-visible, @@ -109,15 +125,9 @@ /* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 32px; + height: 34px; align-items: center; box-sizing: border-box; - color: var(--dsw-alias-label-secondary); -} - -.projectRow .title { - font-size: 12px; - font-weight: 500; } /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ @@ -127,7 +137,7 @@ } .sessionRow .title { - margin: 0 8px; + margin: 0 6px 0 4px; } .slot { @@ -164,28 +174,9 @@ white-space: nowrap; } -.folderActive { - color: var(--dsw-alias-label-primary); -} - -/* Project leading slot: folder by default, expand arrow on row hover. */ -.projectRow .chevron { - display: none; -} -.projectRow:is(:hover, :focus-within, .menuOpen) .chevron { - display: inline-flex; -} -.projectRow:is(:hover, :focus-within, .menuOpen) .folder { - display: none; -} - -/* Expand arrow (filled triangle): points right closed, rotates to point down open. */ -.arrow { - transition: transform 150ms var(--ds-ease-in-out); -} - -.arrowOpen { - transform: rotate(90deg); +/* Expansion is visible immediately after clicking, not only after pointer leave. */ +.folderExpanded { + color: var(--dsw-alias-state-business-primary); } .projectText { @@ -201,7 +192,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 13px; + font-size: 14px; line-height: 20px; } @@ -217,39 +208,11 @@ outline: none; } -/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls - it at a constant speed to its far edge while the pointer rests on the row, - holds it there, and returns it to the start in one step when the pointer - leaves. The motion is scripted frame by frame (reduced motion jumps), so no - scroll-behavior applies here. */ +/* Keep titles still; the existing hover card supplies the full text. */ .sessionRow .title { flex: 1; } -/* While the marquee travels, both clipping edges fade in over 12px instead of - hard-cutting a character: the left once the title has moved off its start - (data-scrolled), the right while text still remains beyond the cell - (data-clipped) — at the far edge the right fade lifts so the final character - reads at full strength. The mask rides the title span itself, so the status - slot and every other leading cell keep their full color. */ -.sessionRow .title[data-scrolled] { - mask-image: linear-gradient(to right, transparent, #000 12px); -} - -.sessionRow .title[data-clipped] { - mask-image: linear-gradient(to left, transparent, #000 12px); -} - -.sessionRow .title[data-scrolled][data-clipped] { - mask-image: linear-gradient( - to right, - transparent, - #000 12px, - #000 calc(100% - 12px), - transparent - ); -} - .meta { overflow: hidden; text-overflow: ellipsis; @@ -259,14 +222,13 @@ color: var(--dsw-alias-label-tertiary); } -/* Metadata and actions occupy the same grid cell. Visibility changes never - remeasure the title, so entering or leaving a row cannot move its text. */ +/* Only metadata reserves space. Desktop actions float above the trailing + edge rather than taking three button widths from every title. */ .trailing { flex: none; display: grid; align-items: center; justify-items: end; - position: relative; } .trailing:empty { @@ -279,7 +241,7 @@ .time { flex: none; - font-size: 11px; + font-size: 10px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-tertiary); @@ -321,7 +283,8 @@ gap: 2px; } -/* A stable trailing rail, available to both pointer and keyboard users. */ +/* On touch these remain in flow with a 44px menu target. Fine-pointer + actions are overlaid below, so hiding them also frees the title width. */ .rowActions { flex: none; display: inline-flex; @@ -331,9 +294,9 @@ gap: 2px; } -.projectRow:is(:hover, :focus-within, .menuOpen) .rowActions, -.sessionRow:is(:hover, :focus-within, .menuOpen) .rowActions, -.searchResultRow:is(:hover, :focus-within) .rowActions { +.projectRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) .rowActions, +.sessionRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) .rowActions, +.searchResultRow:is(:hover, :focus-visible, :has(:focus-visible)) .rowActions { visibility: visible; pointer-events: auto; } @@ -344,27 +307,33 @@ margin-left: auto; } -.sessionRow:is(:hover, :focus-within, .menuOpen) .time, -.sessionRow:is(:hover, :focus-within, .menuOpen) .pinIndicator { +.sessionRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) .time, +.sessionRow:is(:hover, :focus-visible, :has(:focus-visible), .menuOpen) .pinIndicator { visibility: hidden; } -/* The unclipped hover state drops the ellipsis, which would otherwise cover - the characters the marquee reached. Guarded because a touch tap - latches `:hover` on the row after the title has already returned: an - unguarded rule would strand that row without its ellipsis. */ -@media (hover: hover) { - .sessionRow:hover .title, - .sessionRow.menuOpen .title { - text-overflow: clip; +@media (hover: hover) and (pointer: fine) { + .projectRow > .rowActions, + .sessionRow .rowActions { + position: absolute; + inset-inline-end: 8px; + top: 50%; + transform: translateY(-50%); + z-index: 1; + background: var(--dsh-row-surface); + border-radius: var(--dsw-radius-sm); } -} -/* An open row menu pins the hover affordances (figma: the row keeps its - hover fill while its dropdown is up). */ -.projectRow.menuOpen, -.sessionRow.menuOpen { - background: var(--dsw-alias-interactive-bg-hover); + .projectRow > .rowActions::before, + .sessionRow .rowActions::before { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-end: 100%; + width: 12px; + background: linear-gradient(to right, transparent, var(--dsh-row-surface)); + pointer-events: none; + } } /* Session drag insert marker: a leading chevron and 2px rule between rows, @@ -500,19 +469,3 @@ height: 44px; } } - -/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step - darker (tertiary, #81858C) per the cell spec. */ -.chevron { - color: var(--dsw-alias-label-caption); -} - -@media (prefers-reduced-motion: reduce) { - .sessionRow:hover .title { - text-overflow: ellipsis; - } - .arrow { - animation: none; - transition: none; - } -} diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx index 62484e799..02a3709f7 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -1,8 +1,8 @@ /** * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — - * all data and callbacks arrive via props. Hover swaps (folder->chevron, - * time->ellipsis, action buttons) are CSS-only, and a session row's clipped - * title marquees programmatically while the row is hovered. Workspace row + * all data and callbacks arrive via props. Folder color/shape follows expansion; + * hover swaps for time and action buttons are CSS-only. Titles stay still; dwelling + * on a row reveals its full title in the existing hover card. Workspace row * menus are visual-only except Rename/Delete. A Session row's "..." menu and * its hover buttons are the `sidebar.workspaces.session.menu.item` and * `sidebar.workspaces.session.row.action` lists, rendered through the @@ -11,21 +11,19 @@ * session and workspace hover cards are suppressed while a menu is open. */ import { useEffect, useMemo, useRef, useState } from "react"; -import type { RefObject } from "react"; import clsx from "clsx"; import type { PropsRenderSlots } from "@deepseek-ai/dsh-client-ui-slots"; import { HoverCard, - OpenAIArchiveIcon as IconArchiveOutlineRegular, - OpenAIEditIcon as IconEditOutlineRegular, - OpenAIMoreIcon as IconEllipsisOutlineRegular, + IconArchiveOutlineRegular, + IconEditOutlineRegular, + IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, - OpenAIPlusIcon as IconNewChatOutlineRegular, - OpenAIPinFilledIcon as IconPinFillRegular, - OpenAITrashIcon as IconTrashOutlineRegular, - OpenAICaretRightIcon as IconTriangleRightFillRegular, - OpenAIUnarchiveIcon as IconUnarchiveOutlineRegular, + IconNewChatOutlineRegular, + IconPinFillRegular, + IconTrashOutlineRegular, + IconUnarchiveOutlineRegular, Menu, relativeTime, StateDot, @@ -61,104 +59,6 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { return node.blank ? t("session.new") : node.title || t("session.untitled"); } -/* Overflow this small hides no meaningful tail; scrolling for it reads as an - accidental jitter, so the title stays put. */ -const MIN_TITLE_REVEAL_PX = 8; - -/* Marquee travel speed: slow enough to read the text as it passes. */ -const TITLE_MARQUEE_PX_PER_MS = 0.03; - -/** - * Place the title's scroll position and publish the stylesheet's fade-mask - * hooks: `data-scrolled` while the title has left its start (left fade) and - * `data-clipped` while text remains beyond the right edge (right fade). - * @param title - the row's clipping title element. - * @param left - scroll offset in CSS pixels. - * @param range - the title's maximum scroll offset in CSS pixels. - */ -function placeTitle(title: HTMLSpanElement, left: number, range: number): void { - // jsdom implements no scrollTo; the lane's direct assignment is instant there - // anyway, so both paths land on the same position. - if (typeof title.scrollTo === "function") title.scrollTo({ left, behavior: "instant" }); - else title.scrollLeft = left; - if (left > 0) title.dataset.scrolled = ""; - else delete title.dataset.scrolled; - if (left < range) title.dataset.clipped = ""; - else delete title.dataset.clipped; -} - -/** - * Return the title to its resting state: scrolled to the start with both fade - * masks off, so the resting ellipsis renders at full strength. - * @param title - the row's clipping title element. - */ -function restTitle(title: HTMLSpanElement): void { - if (typeof title.scrollTo === "function") title.scrollTo({ left: 0, behavior: "instant" }); - else title.scrollLeft = 0; - delete title.dataset.scrolled; - delete title.dataset.clipped; -} - -/** - * Marquee a title wider than its one-line cell while its row is hovered: the - * title clips its own text, so entering crawls it at a constant speed until the - * far edge (a fork's incremented title, for example) is in view, then rests - * there under the pointer. Overflow of at most {@link MIN_TITLE_REVEAL_PX} - * stays put — a barely-clipped title moving a few pixels reads as jitter, not a - * reveal. Leaving returns the title to the start in one step, because the - * resting ellipsis and the narrowed cell would otherwise meet the text while it - * travelled back. Reduced motion keeps the title still; the hover card reveals it. - * @param title - ref to the row's clipping title element. - * @returns stable pointer enter/leave handlers for the row. - */ -function useTitleMarquee(title: RefObject): { - enter: () => void; - leave: () => void; -} { - const frame = useRef(0); - useEffect( - () => () => { - cancelAnimationFrame(frame.current); - }, - [], - ); - return useMemo( - () => ({ - enter: (): void => { - /* v8 ignore next -- defensive: the title span renders unconditionally. */ - if (title.current === null) return; - const element = title.current; - const range = element.scrollWidth - element.clientWidth; - if (range <= MIN_TITLE_REVEAL_PX) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - cancelAnimationFrame(frame.current); - let previous: number | undefined; - let started: number | undefined; - let position = 0; - const step = (now: DOMHighResTimeStamp): void => { - started ??= now; - if (now - started < 400) { - frame.current = requestAnimationFrame(step); - return; - } - position += previous === undefined ? 0 : (now - previous) * TITLE_MARQUEE_PX_PER_MS; - previous = now; - placeTitle(element, Math.min(position, range), range); - if (position < range) frame.current = requestAnimationFrame(step); - }; - frame.current = requestAnimationFrame(step); - }, - leave: (): void => { - cancelAnimationFrame(frame.current); - /* v8 ignore next -- defensive: the title span renders unconditionally. */ - if (title.current === null) return; - restTitle(title.current); - }, - }), - [title], - ); -} - /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ function timeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now); @@ -235,11 +135,9 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): "before" | /** * Project (workspace) header row: folder + title; - * hover reveals the chevron and create button, and dwelling on a real - * Workspace shows its hover card (the ungrouped bucket has none). - * `containsCurrent` arrives on the node (derivation fact, no renderer scan). - * @param props.group - derived group node. - * @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed. + * expanded folders stay blue and open even while hovered or keyboard-focused. + * Dwelling on a real Workspace shows its hover card (the ungrouped bucket has none). + * @param props.group - derived group node; expansion owns the folder appearance. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. * @param props.drag - optional workspace-row drag wiring. @@ -249,7 +147,6 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): "before" | */ export function ProjectRowItem({ group, - containsCurrentDescendant = false, onToggle, onCreate, actions, @@ -260,7 +157,6 @@ export function ProjectRowItem({ }: { group: GroupNode; newShortcut?: ShortcutCatalogEntry | undefined; - containsCurrentDescendant?: boolean; onToggle: () => void; onCreate: () => void; /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ @@ -274,7 +170,6 @@ export function ProjectRowItem({ const row = group; // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t("group.ungrouped") : row.label; - const active = containsCurrentDescendant || (group.expanded && group.containsCurrent); const [menuOpen, setMenuOpen] = useState(false); const [contextPoint, setContextPoint] = useState(null); const workspaceMenuItems = [ @@ -315,12 +210,9 @@ export function ProjectRowItem({ } onDragEnd={drag?.end} > - + {row.expanded ? : } - - - {label} @@ -696,8 +588,6 @@ export function SessionNodeItem({ // The menu's open state, bound into the row entries' `useMenuOpenState` hook. const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen]); const rowRef = useRef(null); - const titleRef = useRef(null); - const marquee = useTitleMarquee(titleRef); useEffect(() => { if (onReveal === undefined) return; rowRef.current?.scrollIntoView({ block: "nearest" }); @@ -741,10 +631,6 @@ export function SessionNodeItem({ onClick={() => { onOpen(node.id); }} - onPointerEnter={(e) => { - if (e.pointerType === "mouse") marquee.enter(); - }} - onPointerLeave={marquee.leave} draggable={draggable} onDragStart={ !draggable @@ -794,7 +680,6 @@ export function SessionNodeItem({ * + *, .searchTree > [role="treeitem"] + [role="treeitem"], .groupSection > * + * { - margin-top: 1px; + margin-top: 2px; } .searchStatus { @@ -458,7 +458,7 @@ } .groupSection + .groupSection { - margin-top: 12px; + margin-top: 4px; } .listTopDropIndicator, diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 5782ef82c..dae94f20b 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -18,20 +18,19 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useStat import clsx from "clsx"; import { Button, - OpenAIArchiveIcon as IconArchiveCheckOutlineRegular, - OpenAIEyeOffIcon as IconArchiveOffOutlineRegular, - OpenAIArchiveIcon as IconArchiveOutlineRegular, - OpenAISortIcon as IconChevronsUpDownOutlineRegular, - OpenAIClockIcon as IconClockOutlineRegular, - OpenAICloseIcon as IconCloseFillRegular, - OpenAIListIcon as IconFlatListOutlineRegular, + IconArchiveCheckOutlineRegular, + IconArchiveOffOutlineRegular, + IconArchiveOutlineRegular, + IconChevronsUpDownOutlineRegular, + IconClockOutlineRegular, + IconCloseFillRegular, + IconFlatListOutlineRegular, IconFolderCloseRegular, - OpenAIFolderAddIcon as IconProjectAddOutlineRegular, - OpenAIListIcon as IconQueueOutlineRegular, - OpenAISearchIcon as IconSearchOutlineRegular, - OpenAIViewOptionsIcon as IconSlidersTwoOutlineRegular, - OpenAIFoldersIcon as IconWorkspaceTreeOutlineRegular, - OpenAIEyeIcon, + IconProjectAddOutlineRegular, + IconQueueOutlineRegular, + IconSearchOutlineRegular, + IconSlidersTwoOutlineRegular, + IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, @@ -164,7 +163,7 @@ function ViewOptionsMenu({ { id: "show-archived", label: t("viewOptions.showArchived"), - icon: , + icon: , }, { id: "only-archived", @@ -408,17 +407,6 @@ function SessionTree({ }), ); }, [nestWorkspaces, workspaces]); - const currentAncestors = useMemo(() => { - const keys = new Set(); - for ( - let key = currentGroup === undefined ? undefined : parents.get(currentGroup); - key !== undefined; - key = parents.get(key) - ) { - keys.add(key); - } - return keys; - }, [currentGroup, parents]); const expandedGroups = useMemo(() => { const ancestorKeys = new Set(parents.values()); return [...workspaces.map((workspace) => workspace.workspaceId), UNGROUPED_KEY].filter( @@ -612,7 +600,6 @@ function SessionTree({ row.id === "session.new")} group={group} - containsCurrentDescendant={currentAncestors.has(group.key)} home={home} t={t} onToggle={() => { diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx index 020ad0783..f3c3f246e 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ArchiveSession.tsx @@ -15,8 +15,8 @@ import type {} from "@deepseek-ai/dsh-schedule/client"; import type {} from "@deepseek-ai/dsh-subagent/client"; import { Button, - OpenAIArchiveIcon as IconArchiveOutlineRegular, - OpenAIUnarchiveIcon as IconUnarchiveOutlineRegular, + IconArchiveOutlineRegular, + IconUnarchiveOutlineRegular, MenuItemButton, Modal, Tooltip, diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx index 0e78408d0..b4eee3fed 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -1,8 +1,5 @@ /** The fork action: one `sidebar.workspaces.session.menu.item` row. */ -import { - OpenAIBranchIcon as IconBranchOutlineRegular, - MenuItemButton, -} from "@deepseek-ai/dsh-client-ui-primitives"; +import { IconBranchOutlineRegular, MenuItemButton } from "@deepseek-ai/dsh-client-ui-primitives"; import type { ForkSessionInjected, SessionMenuItemProps } from "../contract/slots.ts"; /** diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx index c707844f7..111ac1ef8 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/PinSession.tsx @@ -7,8 +7,8 @@ * notice) lives in the injected callbacks, not here. */ import { - OpenAIPinFilledIcon as IconPinFillRegular, - OpenAIPinIcon as IconPinOutlineRegular, + IconPinFillRegular, + IconPinOutlineRegular, MenuItemButton, Tooltip, } from "@deepseek-ai/dsh-client-ui-primitives"; diff --git a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx index 29ca82ae2..f0e530bd3 100644 --- a/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx +++ b/apps/web-ui/packages/client/ui-workspace/src/client/session-actions/RenameSession.tsx @@ -7,7 +7,7 @@ import { useRef, useState } from "react"; import { Button, - OpenAIEditIcon as IconEditOutlineRegular, + IconEditOutlineRegular, MenuItemButton, Modal, } from "@deepseek-ai/dsh-client-ui-primitives"; diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index fd3cfef63..217a27bf7 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -46,11 +46,11 @@ npm run start:web -- --port 3181 ## 会话侧栏 -侧栏参考 Codex 的紧凑导航布局:弱化项目标题与时间,区分当前选中行和悬停行,保留 Harness 身份。元信息和快捷操作共用固定尾部区域,悬停、键盘聚焦或打开菜单不挤动标题;长标题停留后缓慢展开,减少动态效果时保持静止。新会话使用轻量导航行而非突出卡片。 +侧栏以 DSH 的轻量视觉为底:项目与会话使用一致的 14px 字号,项目组之间保持 4px 间距,时间作为次要信息,保留 Harness 身份及柔和的选中/悬停底色。桌面端右侧操作仅在悬停、键盘聚焦或菜单打开时覆盖显示,不再为三个按钮常驻预留标题宽度;覆盖层与当前行同色,带轻量渐隐边缘。标题位置和宽度不因按钮出现而变化,文字保持静止,停留通过已有详情卡片查看完整标题。新会话使用轻量导航行而非突出卡片。 项目分组初始显示 5 个普通会话,平铺初始显示 10 个,每次再显示 10 个;置顶、草稿、运行中、当前会话及搜索定位目标保持可见,不为定位一个旧会话展开全部历史。这是已有目录的前端展示窗口,不是 Host 目录分页;仍使用同一目录、项目归属和原生 Thread ID。箭头键移动焦点,Enter/空格打开,左右键展开/收起项目,Home/End 定位首尾;移动焦点不会预读正文。右键或 Shift+F10 打开现有操作菜单,Escape/外部点击关闭,不因鼠标短暂离开而消失。手机端显示明确的菜单按钮,主要会话行及菜单按钮至少 44px,快捷归档/置顶收进菜单以避免误触。 -项目行与分组菜单的文件夹图标保留 DSH 原有的轻量 16px 版本,项目行随展开/收起切换打开/闭合造型,悬停时换为展开箭头。其他通用图标使用 OpenAI 公开的 MIT SVG 图标子集:展开箭头、新会话、搜索、视图选项、置顶、归档、改名、分叉、导入、设置及侧栏开关。保留原始 SVG 轮廓和填充规则,随当前文字颜色适配主题。只引入必要 SVG 数据,不引入整套 Apps SDK,不打包私有 Desktop 动画资源;公开版本的个别字形与当前 Desktop 私有版本并非逐像素相同。Harness 品牌、权限含义及真实状态不替换为 Codex 身份,远程图标/连接状态也不凭截图伪造。版权与许可文本随 Web 发行打包。 +文件夹、新会话、搜索、置顶、归档、菜单和设置等通用图标统一使用已有 DSH 图标,不混用另一套粗细和轮廓。文件夹保持轻量 16px 图形:展开时使用蓝色打开造型,收起时恢复灰色闭合造型。颜色直接跟随展开状态,不要求先选中该项目内的会话;鼠标悬停、键盘聚焦和打开菜单时仍保留文件夹,不再替换为箭头。展开/收起继续通过整行点击或左右键操作,`aria-expanded` 保持原有语义。Harness 品牌、权限含义及真实状态保持原义,不替换为 Codex 身份,也不根据参考截图伪造远程连接状态。Codingns4DSH 只作为交互参考,不引入其插件代码、DOM/Fiber 注入或额外 Host。 状态继续来自已有投影;未接入的原生审批/未读能力不伪造。正文通用运行文案使用“正在处理 / Working”,不暗示某个 Model、推理模式或深度研究能力。 diff --git a/packages/web-server/scripts/pack.ts b/packages/web-server/scripts/pack.ts index 9ca3c5256..d470a9b29 100644 --- a/packages/web-server/scripts/pack.ts +++ b/packages/web-server/scripts/pack.ts @@ -135,7 +135,6 @@ for (const plugin of extraAdapters) { cpSync(join(repoRoot, "LICENSE"), join(out, "LICENSE")); mkdirSync(join(out, "licenses"), { recursive: true }); cpSync(join(frontendRoot, "LICENSE"), join(out, "licenses/DSH-MIT.txt")); -cpSync(join(frontendRoot, "OPENAI_ICONS_LICENSE.txt"), join(out, "licenses/OPENAI-ICONS-MIT.txt")); cpSync(join(frontendRoot, "THIRD_PARTY_NOTICES.md"), join(out, "licenses/THIRD_PARTY_NOTICES.md")); writeFileSync( join(out, "package.json"), @@ -181,7 +180,6 @@ The default listener is localhost. For phones, use an HTTPS reverse proxy such a PWA installation and Web Push need HTTPS. Do not expose \`--no-auth\` to a network. This distribution includes LGPL-licensed CodexHost code and MIT-licensed DSH-derived UI. -Navigation SVGs include a subset of @openai/apps-sdk-ui 0.2.2 (MIT, Copyright 2025 OpenAI). Retained license texts and dependency notices are in LICENSE and licenses/. `, ); diff --git a/packages/web-server/test/browser/ch-sidebar.test.ts b/packages/web-server/test/browser/ch-sidebar.test.ts index df4f564e4..bbff051c9 100644 --- a/packages/web-server/test/browser/ch-sidebar.test.ts +++ b/packages/web-server/test/browser/ch-sidebar.test.ts @@ -17,7 +17,7 @@ async function shot(page: Page, name: string): Promise { for (const mobile of [false, true]) { it( - `Codex-style sidebar layout and interactions (${mobile ? "touch" : "desktop"})`, + `light DSH sidebar keeps titles readable and controls usable (${mobile ? "touch" : "desktop"})`, { timeout: 60_000 }, async (t) => { const host = new FakeChHost(); @@ -28,6 +28,8 @@ for (const mobile of [false, true]) { `Conversation ${String(i).padStart(2, "0")} — review the workspace interaction and layout`, ); } + host.add("other-a", "/computer/reference-a", "Other project A"); + host.add("other-b", "/computer/reference-b", "Other project B"); const debug = await startFakeChDebugger(host); t.after(() => debug.close()); const url = await startServer( @@ -65,12 +67,54 @@ for (const mobile of [false, true]) { assert.equal(await folderIcon.getAttribute("stroke-width"), "1"); assert.equal(await folderIcon.getAttribute("aria-hidden"), "true"); const expandedFolderPath = await folderIcon.locator("path").first().getAttribute("d"); + const openColor = await folderIcon.evaluate((element) => getComputedStyle(element).color); + if (!mobile) await folder.hover(); + assert.equal( + await folderIcon.isVisible(), + true, + "open folder must not be replaced by a hover chevron", + ); + const other = page.getByRole("treeitem").filter({ hasText: /^reference-a$/ }); + if ((await other.getAttribute("aria-expanded")) === "true") await other.click(); + const otherIcon = other.locator("svg").first(); + const closedColor = await otherIcon.evaluate((element) => getComputedStyle(element).color); + const closedPath = await otherIcon.locator("path").first().getAttribute("d"); + await other.click(); + assert.equal(await other.getAttribute("aria-expanded"), "true"); + assert.equal(await otherIcon.isVisible(), true); + assert.equal( + await otherIcon.evaluate((element) => getComputedStyle(element).color), + openColor, + "expanding a different project is blue without changing the selected conversation", + ); + assert.notEqual(openColor, closedColor); + assert.notEqual(await otherIcon.locator("path").first().getAttribute("d"), closedPath); + await shot(page, `folder-open-${mobile ? "touch" : "desktop"}`); + await other + .getByRole("button", { name: "Workspace actions for reference-a", exact: true }) + .click(); + await page.getByRole("menu").waitFor(); + assert.equal(await otherIcon.isVisible(), true, "folder stays visible with its menu open"); + assert.equal( + await otherIcon.evaluate((element) => getComputedStyle(element).color), + openColor, + ); + await page.keyboard.press("Escape"); + await other.click(); + assert.equal( + await otherIcon.evaluate((element) => getComputedStyle(element).color), + closedColor, + ); + assert.equal(await otherIcon.locator("path").first().getAttribute("d"), closedPath); + if (!mobile) await page.mouse.move(800, 800); await shot(page, `sidebar-${mobile ? "touch" : "desktop"}-rest`); const row = rows.first(); await row.locator('[data-harness-id="fake"]').waitFor(); - assert.equal(await row.locator('[data-openai-icon="DotsHorizontal"]').count(), 1); - assert.equal(await row.locator('[data-openai-icon="Archive"]').count(), 1); - assert.equal(await row.locator('[data-openai-icon="Pin"]').count(), 1); + assert.equal( + await page.locator("[data-openai-icon]").count(), + 0, + "navigation uses one DSH icon family", + ); const trigger = row.getByRole("button", { name: /^Session actions for/ }); if (mobile) { const bounds = await trigger.boundingBox(); @@ -96,6 +140,22 @@ for (const mobile of [false, true]) { } else { const title = row.locator("[data-session-title]"); const resting = await title.boundingBox(); + const rowBox = await row.boundingBox(); + assert.ok( + resting && rowBox && resting.width > rowBox.width * 0.66, + "resting title must not lose three button widths to hidden actions", + ); + const groupA = page.getByRole("treeitem").filter({ hasText: /^reference-a$/ }); + const groupB = page.getByRole("treeitem").filter({ hasText: /^reference-b$/ }); + for (const group of [groupA, groupB]) { + if ((await group.getAttribute("aria-expanded")) === "true") await group.click(); + } + const a = await groupA.boundingBox(), + b = await groupB.boundingBox(); + assert.ok( + a && b && b.y - a.y - a.height >= 3 && b.y - a.y - a.height <= 5, + "closed projects use compact 4px spacing", + ); await row.hover(); await trigger.waitFor(); const hovered = await title.boundingBox(); @@ -103,13 +163,39 @@ for (const mobile of [false, true]) { resting && hovered && Math.abs(resting.width - hovered.width) < 1, "hover must not squeeze the title", ); + const fullTitle = await title.textContent(); + assert.ok(fullTitle); + await page.getByRole("button").filter({ hasText: fullTitle }).waitFor(); + assert.equal( + await title.evaluate((element) => element.scrollLeft), + 0, + "titles stay still on hover", + ); + for (const button of [ + trigger, + row.getByRole("button", { name: "Archive session", exact: true }), + row.getByRole("button", { name: "Pin session", exact: true }), + ]) { + const box = await button.boundingBox(); + assert.ok( + box && rowBox && box.x >= rowBox.x && box.x + box.width <= rowBox.x + rowBox.width, + ); + } await shot(page, "sidebar-desktop-hover"); await row.getByRole("button", { name: "Pin session", exact: true }).click(); await row.getByRole("button", { name: "Unpin session", exact: true }).waitFor(); - assert.ok((await row.locator('[data-openai-icon="PinFilled"]').count()) > 0); + await page.mouse.move(800, 800); + await row.getByRole("img", { name: "Pinned", exact: true }).waitFor(); await shot(page, "sidebar-desktop-pinned"); + await row.hover(); await row.getByRole("button", { name: "Unpin session", exact: true }).click(); await row.getByRole("button", { name: "Pin session", exact: true }).waitFor(); + await page.mouse.move(800, 800); + assert.equal( + await trigger.isVisible(), + false, + "mouse actions must not leave a sticky toolbar", + ); await row.click({ button: "right" }); await page.getByRole("menu").waitFor(); await page.mouse.move(1000, 800); @@ -176,13 +262,14 @@ for (const mobile of [false, true]) { await shot(page, "sidebar-desktop-dark"); await page.getByRole("button", { name: "View options", exact: true }).click(); await page.getByRole("menuitem", { name: "In one list", exact: true }).click(); - assert.ok((await rows.count()) >= 10 && (await rows.count()) <= 11); + const flatRows = page.locator('[data-row-key^="session:"]'); + assert.ok((await flatRows.count()) >= 10 && (await flatRows.count()) <= 11); assert.equal( await page.locator(`[data-row-key="${lastKey}"]`).getAttribute("aria-current"), "page", ); await page.getByRole("button", { name: "Show 10 more", exact: true }).click(); - assert.ok((await rows.count()) >= 20 && (await rows.count()) <= 21); + assert.ok((await flatRows.count()) >= 20 && (await flatRows.count()) <= 21); await shot(page, "sidebar-flat-paged"); } assert.deepEqual(errors, []); diff --git a/packages/web-server/test/distribution/source-plugins.test.ts b/packages/web-server/test/distribution/source-plugins.test.ts index 90fb8e31b..e15d2247a 100644 --- a/packages/web-server/test/distribution/source-plugins.test.ts +++ b/packages/web-server/test/distribution/source-plugins.test.ts @@ -48,10 +48,6 @@ it( readFileSync(join(output, "licenses/DSH-MIT.txt")), readFileSync(join(repoRoot, "apps/web-ui/LICENSE")), ); - assert.deepEqual( - readFileSync(join(output, "licenses/OPENAI-ICONS-MIT.txt")), - readFileSync(join(repoRoot, "apps/web-ui/OPENAI_ICONS_LICENSE.txt")), - ); assert.ok(readFileSync(join(output, "licenses/THIRD_PARTY_NOTICES.md")).length > 0); }, ); diff --git a/packages/web-server/test/support/server.ts b/packages/web-server/test/support/server.ts index f116aeac8..71c8b7364 100644 --- a/packages/web-server/test/support/server.ts +++ b/packages/web-server/test/support/server.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import type { TestContext } from "node:test"; @@ -21,10 +21,16 @@ export async function startServer( args: string[], ): Promise { const data = mkdtempSync(join(tmpdir(), "codexhost-web-test-")); + // UI-only checks may target an already packaged server, without compiling + // unrelated runtime work in progress. Distribution tests keep their own entry. + const bundle = process.env.CODEXHOST_TEST_WEB_BUNDLE; + const serverEntry = bundle + ? entry.map((part) => (part === "src/main.ts" ? resolve(bundle) : part)) + : entry; const child = spawn( process.execPath, [ - ...entry, + ...serverEntry, "--host", "127.0.0.1", "--port", From aedbf93059ecc1ac9a241cfe3d789ce524444451 Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 10 Oct 2026 19:55:20 +0800 Subject: [PATCH 007/106] feat: synchronize shared Host clients and native icon presentation Add authenticated owner/App channels with bounded invalidation replay, authoritative snapshots, submission receipts and shared interaction arbitration. Preserve Desktop projection behavior and recover Web display generations after Host restart. Render plugin iconStyle consistently in sidebar and picker without Pi-specific overrides. Validation: 149 focused Host/protocol tests; 58 Web backend tests; 19 browser tests; distribution checks; TypeScript, lint/boundaries and UI build. An initial parallel native-presentation test opened the old session's picker before navigation completed; it now waits for the new draft before interacting. Real preview checks were read-only; no live model submission or approval was performed. Frontend strict typecheck coverage remains limited to the existing repository configuration. --- .../client/HostConnectionStatus.module.css | 8 + .../src/client/HostConnectionStatus.tsx | 51 +++ .../src/client/SessionHarnessIcon.tsx | 25 +- .../client/ui-codexhost/src/client/index.ts | 14 + .../client/ui-codexhost/src/client/locales.ts | 8 + .../src/client/HarnessSelect.tsx | 17 +- .../client/ui-primitives/package.json | 1 + .../ui-primitives/src/RemotePluginIcon.tsx | 143 +++++++ .../client/ui-primitives/src/index.ts | 1 + docs/architecture/host-client-channel.md | 65 ++++ docs/index.md | 1 + docs/product/standalone-web.md | 14 +- package-lock.json | 1 + .../src/host-client-channel.ts | 257 +++++++++++++ packages/desktop-control/src/index.ts | 5 + packages/host-runtime/src/app-server-host.ts | 233 ++++++++++- .../host-runtime/src/client-channel-events.ts | 52 +++ .../host-runtime/src/client-channel-server.ts | 159 ++++++++ .../src/client-command-receipts.ts | 89 +++++ packages/host-runtime/src/run-host-runtime.ts | 10 + .../host-runtime/test/client-channel.test.ts | 362 ++++++++++++++++++ .../protocol-core/src/codex-ui-projector.ts | 35 +- .../shared-contracts/src/client-channel.ts | 147 +++++++ packages/shared-contracts/src/index.ts | 1 + packages/web-server/README.md | 4 +- packages/web-server/scripts/preview.ts | 1 + packages/web-server/src/ch-client-gateway.ts | 116 ++++++ packages/web-server/src/ch-host-client.ts | 64 +++- packages/web-server/src/ch-interactions.ts | 153 ++++++++ packages/web-server/src/ch-realtime.ts | 93 +++++ packages/web-server/src/ch-sessions.ts | 138 ++++++- packages/web-server/src/ch-thread-history.ts | 22 +- packages/web-server/src/ch-thread-view.ts | 93 ++++- .../web-server/src/harness-icon-styles.ts | 18 + packages/web-server/src/harnesses.ts | 2 +- packages/web-server/src/main.ts | 43 ++- packages/web-server/src/preview-launch.ts | 4 + packages/web-server/src/session-log.ts | 10 + .../test/browser/ch-realtime.test.ts | 207 ++++++++++ .../test/browser/harness-icons.test.ts | 144 +++++++ .../test/browser/native-presentation.test.ts | 3 + .../web-server/test/ch-client-gateway.test.ts | 135 +++++++ packages/web-server/test/ch-realtime.test.ts | 313 +++++++++++++++ .../test/harness-icon-styles.test.ts | 41 ++ .../web-server/test/support/ch-channel.ts | 169 ++++++++ packages/web-server/test/support/ch-host.ts | 9 +- packages/web-server/test/support/server.ts | 3 +- 47 files changed, 3385 insertions(+), 99 deletions(-) create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.module.css create mode 100644 apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.tsx create mode 100644 apps/web-ui/packages/client/ui-primitives/src/RemotePluginIcon.tsx create mode 100644 docs/architecture/host-client-channel.md create mode 100644 packages/desktop-control/src/host-client-channel.ts create mode 100644 packages/host-runtime/src/client-channel-events.ts create mode 100644 packages/host-runtime/src/client-channel-server.ts create mode 100644 packages/host-runtime/src/client-command-receipts.ts create mode 100644 packages/host-runtime/test/client-channel.test.ts create mode 100644 packages/shared-contracts/src/client-channel.ts create mode 100644 packages/web-server/src/ch-client-gateway.ts create mode 100644 packages/web-server/src/ch-interactions.ts create mode 100644 packages/web-server/src/ch-realtime.ts create mode 100644 packages/web-server/src/harness-icon-styles.ts create mode 100644 packages/web-server/test/browser/ch-realtime.test.ts create mode 100644 packages/web-server/test/browser/harness-icons.test.ts create mode 100644 packages/web-server/test/ch-client-gateway.test.ts create mode 100644 packages/web-server/test/ch-realtime.test.ts create mode 100644 packages/web-server/test/harness-icon-styles.test.ts create mode 100644 packages/web-server/test/support/ch-channel.ts diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.module.css b/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.module.css new file mode 100644 index 000000000..5a8661339 --- /dev/null +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.module.css @@ -0,0 +1,8 @@ +.status { + font-size: 12px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} +.status[data-native-connection="reconnecting"] { + color: var(--dsw-alias-label-primary); +} diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.tsx b/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.tsx new file mode 100644 index 000000000..5eb3a3f9e --- /dev/null +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/HostConnectionStatus.tsx @@ -0,0 +1,51 @@ +import { useState, useSyncExternalStore } from "react"; +import type { SessionListState } from "@deepseek-ai/dsh-api-session-controller/client"; +import type { HostObservable, PropsLocale } from "@deepseek-ai/dsh-client-ui-slots"; +import type { SessionId } from "@deepseek-ai/dsh-session/types"; +import { Toast } from "@deepseek-ai/dsh-client-ui-primitives"; +import css from "./HostConnectionStatus.module.css"; + +declare module "@deepseek-ai/dsh-session-projection/types" { + interface SessionProjectionMap { + nativeConnection: { state: "connected" | "reconnecting"; message?: string } | null; + nativeInteractionError: string | null; + } +} + +export function HostConnectionStatus({ + sessionId, + list, + t, +}: { + sessionId: SessionId; + list: HostObservable; +} & PropsLocale<"codexhostImport">) { + const state = useSyncExternalStore( + (fn) => list.subscribe(fn), + () => list.getSnapshot().byId[sessionId]?.projectionValues?.nativeConnection?.state, + ); + const error = useSyncExternalStore( + (fn) => list.subscribe(fn), + () => + list.getSnapshot().byId[sessionId]?.projectionValues?.nativeInteractionError ?? + list.getSnapshot().byId[sessionId]?.projectionValues?.nativeConnection?.message, + ); + const [dismissed, setDismissed] = useState(""); + const key = `${sessionId}:${error ?? ""}`; + if (!state) return null; + return ( + <> + + {state === "connected" ? t("connection.live") : t("connection.reconnecting")} + + {error && dismissed !== key ? ( + setDismissed(key)} /> + ) : null} + + ); +} diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx b/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx index ed048435a..313dc521a 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/SessionHarnessIcon.tsx @@ -1,9 +1,9 @@ /** Sidebar branding reads durable per-session metadata without retaining/opening history. */ -import { useState, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; import type { SessionListState } from "@deepseek-ai/dsh-api-session-controller/client"; import type { HostObservable } from "@deepseek-ai/dsh-client-ui-slots"; import type { SessionId } from "@deepseek-ai/dsh-session/types"; -import { IconDataOutlineRegular } from "@deepseek-ai/dsh-client-ui-primitives"; +import { IconDataOutlineRegular, RemotePluginIcon } from "@deepseek-ai/dsh-client-ui-primitives"; declare module "@deepseek-ai/dsh-session-projection/types" { interface SessionProjectionMap { @@ -23,7 +23,6 @@ export function SessionHarnessIcon({ (fn) => list.subscribe(fn), () => list.getSnapshot().byId[sessionId]?.projectionValues?.harnessIdentity, ); - const [failedId, setFailedId] = useState(null); if (!identity) return null; return ( - {failedId === identity.id ? ( - - ) : ( - setFailedId(identity.id)} - /> - )} + } + /> ); } diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts index 88b1e8926..b2ed45681 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/index.ts @@ -14,6 +14,8 @@ import type {} from "@deepseek-ai/dsh-client-ui-settings/client"; import { ImportIcon } from "./ImportIcon.tsx"; import type {} from "@deepseek-ai/dsh-api-session-controller/client"; import { SessionHarnessIcon } from "./SessionHarnessIcon.tsx"; +import { HostConnectionStatus } from "./HostConnectionStatus.tsx"; +import type {} from "@deepseek-ai/dsh-client-ui-conversation/client"; import type {} from "@deepseek-ai/dsh-api-remotes/client"; import { ensureServiceWorker } from "./notifications.ts"; import { NotificationsRow } from "./NotificationsRow.tsx"; @@ -94,6 +96,18 @@ export function apply(ctx: ClientContext): void { ), ); + ctx.slots.inject("conversation.session.header.utilities", () => + ctx.slots.register( + { + name: "conversation.session.header.utilities", + id: "codexhost.connection", + locale: NS, + inject: () => ({ list: ctx.sessions.list }), + }, + HostConnectionStatus, + ), + ); + ctx.effect( () => ctx.remote.$on("codexhost/session-bound", (draftId, threadId) => { diff --git a/apps/web-ui/packages/client/ui-codexhost/src/client/locales.ts b/apps/web-ui/packages/client/ui-codexhost/src/client/locales.ts index b0dd376fa..bfd6e8d37 100644 --- a/apps/web-ui/packages/client/ui-codexhost/src/client/locales.ts +++ b/apps/web-ui/packages/client/ui-codexhost/src/client/locales.ts @@ -1,6 +1,10 @@ /** Copy for the CodexHost import panel. */ export const en = { panel: "Import sessions", + "connection.live": "Live", + "connection.liveHint": "Connected to the shared CH owner", + "connection.reconnecting": "Reconnecting…", + "connection.reconnectingHint": "CH is unavailable. Commands are not retried automatically.", title: "Import sessions", subtitle: "Continue a session you started in a Harness on this computer. Its history is loaded when you open it.", @@ -31,6 +35,10 @@ export type ImportLocaleKey = keyof typeof en; export const zh: Record = { panel: "导入会话", + "connection.live": "实时同步", + "connection.liveHint": "已连接同一个 CH 会话所有者", + "connection.reconnecting": "正在重连…", + "connection.reconnectingHint": "CH 暂不可用,操作不会自动重试。", title: "导入会话", subtitle: "继续你在这台电脑上用 Harness 开过的会话,打开时会加载完整历史。", search: "按标题或文件夹搜索", diff --git a/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx index da5cbc3c0..2ded48f85 100644 --- a/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx +++ b/apps/web-ui/packages/client/ui-model-selection/src/client/HarnessSelect.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useSyncExternalStore } from "react"; import { IconDataOutlineRegular, + RemotePluginIcon, IconWarningOutlineRegular, Menu, type MenuEntry, @@ -14,16 +15,14 @@ import css from "./HarnessSelect.module.css"; /** Show the plugin's own brand asset, with a generic glyph when it has no icon. */ function HarnessIcon({ id }: { id: string }) { - const [failed, setFailed] = useState(false); - return failed ? ( - - ) : ( - setFailed(true)} + presentationUrl="/harness-icons/presentation.json" + size={20} + className={css.icon} + fallback={} /> ); } diff --git a/apps/web-ui/packages/client/ui-primitives/package.json b/apps/web-ui/packages/client/ui-primitives/package.json index 4f9bb1cac..c1ebe3e0a 100644 --- a/apps/web-ui/packages/client/ui-primitives/package.json +++ b/apps/web-ui/packages/client/ui-primitives/package.json @@ -13,6 +13,7 @@ "./package.json": "./package.json" }, "dependencies": { + "@codexhost/shared-contracts": "*", "react": "^18.2.0", "react-dom": "^18.2.0", "mdast-util-from-markdown": "^2.0.3", diff --git a/apps/web-ui/packages/client/ui-primitives/src/RemotePluginIcon.tsx b/apps/web-ui/packages/client/ui-primitives/src/RemotePluginIcon.tsx new file mode 100644 index 000000000..39dccb76f --- /dev/null +++ b/apps/web-ui/packages/client/ui-primitives/src/RemotePluginIcon.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { HarnessPluginDescriptor } from "@codexhost/shared-contracts"; + +type Presentation = NonNullable; +type Presentations = Record; +const pending = new Map>(); +const resolved = new Map(); + +/** One metadata request per catalog URL, shared by rows and picker entries. */ +function load(url: string): Promise { + const existing = pending.get(url); + if (existing) return existing; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + const request = fetch(url, { credentials: "same-origin", signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new Error("Icon presentation unavailable"); + const value: unknown = await response.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Invalid icon catalog"); + const icons = value as Presentations; + resolved.set(url, icons); + return icons; + }) + .catch((error) => { + pending.delete(url); + throw error; + }) + .finally(() => clearTimeout(timeout)); + pending.set(url, request); + return request; +} + +/** A display-only consumer of validated plugin presentation data. Never insert + * plugin SVG markup; render only the contract's path primitives. */ +export function RemotePluginIcon({ + id, + src, + presentationUrl, + size, + className, + fallback, +}: { + id: string; + src: string; + presentationUrl: string; + size: number; + className?: string; + fallback: ReactNode; +}): ReactNode { + const [catalog, setCatalog] = useState<{ url: string; icons: Presentations } | null>(() => { + const icons = resolved.get(presentationUrl); + return icons ? { url: presentationUrl, icons } : null; + }); + useEffect(() => { + let active = true; + void load(presentationUrl).then( + (icons) => { + if (active) setCatalog({ url: presentationUrl, icons }); + }, + () => { + if (active) setCatalog(null); + }, + ); + return () => { + active = false; + }; + }, [presentationUrl]); + // Avoid flashing a fixed-black, padded source image while vector metadata loads. + if (!catalog || catalog.url !== presentationUrl) return fallback; + const presentation = catalog.icons[id]; + if (presentation?.vector) { + const { paths, color, viewBox } = presentation.vector; + return ( + + ); + } + return ( + + ); +} + +function BitmapIcon({ + src, + size, + className, + presentation, + fallback, +}: { + src: string; + size: number; + className: string | undefined; + presentation: Presentation | null | undefined; + fallback: ReactNode; +}): ReactNode { + const [failed, setFailed] = useState(false); + if (failed) return fallback; + return ( + setFailed(true)} + /> + ); +} diff --git a/apps/web-ui/packages/client/ui-primitives/src/index.ts b/apps/web-ui/packages/client/ui-primitives/src/index.ts index 36b4eddd2..74763dd40 100644 --- a/apps/web-ui/packages/client/ui-primitives/src/index.ts +++ b/apps/web-ui/packages/client/ui-primitives/src/index.ts @@ -4,6 +4,7 @@ export type { CodeToolbarLabels } from "./CodeToolbar.tsx"; export { StateDot } from "./StateDot.tsx"; +export { RemotePluginIcon } from "./RemotePluginIcon.tsx"; export type { StateDotState } from "./StateDot.tsx"; export { DisclosureRow } from "./DisclosureRow.tsx"; export type { DisclosureRowProps } from "./DisclosureRow.tsx"; diff --git a/docs/architecture/host-client-channel.md b/docs/architecture/host-client-channel.md new file mode 100644 index 000000000..0aea75370 --- /dev/null +++ b/docs/architecture/host-client-channel.md @@ -0,0 +1,65 @@ +# CH 客户端通道 v1 + +## 所有权与入口 + +GUI、Web 和其他 App 操作同一个 `AppServerHost`。Adapter、原生引用、Mapping Store 和 Thread/Turn 执行仍由原 Host 管理。客户端通道不启动 Harness、不拥有第二份 Mapping Store,也不保存 transcript。 + +- `shared-contracts/client-channel` 定义浏览器安全的版本、游标、快照和交互契约。 +- `host-runtime` 提供本地认证 HTTP 通道,复用原请求分派、投影器、配置观察器和交互响应路径。 +- `desktop-control` 的公共 `HostClientChannel` 负责 Node 端发现、通知恢复和只读快照访问;命令不自动重试。 +- `web-server` 一方面投影到现有 DSH UI,另一方面提供 `/api/ch/v1/*` 通用客户端网关。原生 App 可以消费此协议,不必实现 DSH 渲染日志、Adapter 或新的同步所有者。 + +当前生命周期接入 Launcher 启动的本地 Desktop Host,跟随该 Host 启停。远程/shared-socket 服务、脱离 Desktop 的常驻所有者和移动端后台推送不是此版本的能力。GUI 项目归属仍通过既有 CDP 只读读取,不把执行 `cwd` 当作项目。 + +## 认证与传输 + +Host 只监听 `127.0.0.1`,使用随机 Bearer token,拒绝带 Origin 的直接浏览器请求。私有描述文件位于 `$CODEXHOST_DATA_DIR/client-hosts`,默认 `~/.codexhost/client-hosts`,文件权限为 `0600`;PID 和 epoch 区分不同实例。发现时验证存活 PID、文件类型、大小和 Unix 所有者/权限,并通过打开的文件句柄校验,拒绝符号链接。描述文件不是会话索引。 + +浏览器/App 使用 Web 原有 Cookie/Bearer 认证及同源防护;Host 私有 token 不下发给客户端。公网使用仍需 TLS/可信网络部署,移动端仍要实现凭据保管、挂起恢复和通知生命周期。开发用 `--no-auth` 不应暴露到不可信网络。 + +| 操作 | Host 本地路径 | Web/App 路径 | +| --- | --- | --- | +| 通知 | `GET /v1/events?epoch=…&after=…` | `GET /api/ch/v1/events?epoch=…&after=…` | +| 受限 RPC | `POST /v1/rpc` | `POST /api/ch/v1/rpc` | +| Thread 快照 | `POST /v1/snapshot` | `POST /api/ch/v1/snapshot` | +| 回答交互 | `POST /v1/respond` | `POST /api/ch/v1/respond` | + +POST 使用 JSON,正文上限 1 MiB;返回 `{result: …}` 或 `{error: {code, message}}`。RPC 参数为 `{method, params}`,快照参数为 `{threadId}`,回答参数为 `{epoch, threadId, requestId, result}`。可用 RPC 在 `CLIENT_CHANNEL_METHODS` 中;不允许 `thread/resume`、官方 Codex 创建或非本地 external Thread 操作。具体 Harness 能力仍须通过已有 inspect 契约查询,不能因 RPC 在名单内便假定该 Harness 支持它。 + +通知是 `application/x-ndjson`,空行是心跳,不是事件。慢消费者超过有界写缓冲时断开,不能阻塞 GUI。Node 通道断线重新发现描述文件;网关在底层 Host 离线时结束 App 的通知流,App 可携带游标重新连接。 + +## 通知、快照与恢复 + +通知有两种形状: + +- `hello {version: 1, cursor: {epoch, sequence}, reset}`。 +- `changed {cursor: {epoch, sequence}, threadId, method}`。 + +**changed 是失效通知,不是可追加的 token。** 首次先建立订阅,再取快照;读取期间收到新通知必须再校验。快照给出同一 Host 的元数据、最近 5 轮 full/desc、下一页 cursor、生效配置和当前待处理交互。热会话在不让出执行权的同步步骤中复制已接受状态及通知水位;状态可以包含尚待发出的后续通知所描述的变化,因此禁止把通知当增量再次拼接到快照上。 + +Host 和 Node 网关分别保留最近 512 条轻量通知。游标仍在窗口中时,`hello.reset=false`,随后重放大于旧游标的通知;hello 的 cursor 是本轮重放终点,不应在处理重放前覆盖旧消费游标。窗口不足、游标超前或 epoch 不同时,`reset=true`,重新读取所需 Thread 的快照。每次重连都重新校验已关注的 Thread,包括仍待处理的交互;不需要持久事件队列。 + +Web 将同一 Thread 的变化按 100ms 合并,读取中再次变化会安排后续读取,只对仍有 follow 的 Thread 读取正文。事件模式不运行原来的 1.5 秒历史轮询。Host 换代时退役旧 Web 展示流,以高于旧游标的新内存序号重新打开;客户端保留旧正文直到新的权威 baseline 到达,即使恢复后的原生 Turn/Item ID 改变,也不将其追加成重复消息。较早历史继续走原生分页,不为了同步一次读取全部历史。 + +同一 Host 内检测到原尾部消失或非追加正文改写时,旧展示缓冲失效并报告错误,重新打开页面取得当前历史;不把历史重写伪装成连续追加。v1 尚不是逐 Item patch/token 的低带宽协议,长工具输出快照仍可能较大。目录仍保留全量查询及差量发布;每 10 秒校验 GUI 项目元数据,项目变化不保证消息级即时性。 + +## 输入、工具与交互 + +Desktop 专用 `CodexTurnProjector.pendingTurn()` 不包含所有进行中的 Item,不能用作通用客户端完整快照。`snapshotTurn()` 复用同一投影器的 Item 状态,提供 Reasoning、命令输出及工具状态;原 Desktop 通知形状保留。Host 接受的普通用户输入保留给快照;来自客户端通道的输入还沿既有 shared-Thread 投影方式广播给 GUI。`clientUserMessageId`/`clientId` 关联用于撤下 Web 乐观回显,不能靠相同文本猜测确认。 + +审批与提问使用 Host 中现有 request ID,并绑定 epoch、Thread。Web 显示原生 action/选项标签,结果仍由 Host 的原有投影解析器校验。第一份有效响应在异步执行前认领,后续响应不重复调用 Harness;`serverRequest/resolved` 使其他 GUI/Web 卡片撤下。`{resolved:true}` 只表示请求已不再待处理,不证明当前客户端的答案获胜,更不代表工具执行成功。 + +断线会撤下 Web 的交互卡片,不自动回答或拒绝 Harness;重新连接只恢复快照中仍 pending 的请求。配置通知只报告观察器已接受的实际状态,多个配置命令不被包装成一个虚假的原子事务。草稿、阅读位置、输入框内容与当前选中 Thread 留在各端。 + +## 写操作边界 + +- 一个客户端 RPC 只提交一次;超时、响应断开或结果不明时不得自动重发。 +- `turn/start` 以 Thread + `clientUserMessageId` 在当前进程内去重,相同 ID 不同输入拒绝;最多保留 256 条回执,不驱逐仍进行中的请求。确定未提交的参数错误/忙碌拒绝可释放回执;未知结果保留在窗口内。 +- 这是有界、进程内的回执,不是跨 Host 重启、窗口驱逐或历史恢复的持久 exactly-once 保证。 +- 原生历史刷新后,可在回执窗口内补回已接受输入的 `clientId`,不建立持久消息映射。 +- 写入已确认后,随后的快照读取失败不会改报成“发送被拒绝”。 +- 同时发送服从现有 Host/Harness 的忙碌、队列或 Steering 语义;不会另开 Adapter 伪造并发。 + +## 验证范围 + +使用真实 Host 类、公共 Node 客户端、临时 HTTP 服务、Fake Harness 和双浏览器验证;不发送真实模型消息或真实审批。覆盖认证/同源隔离、GUI 与 Web 消息广播、完整热状态、并发输入、审批竞争、提问恢复、配置/停止、请求去重、通知窗口、读取竞态以及 epoch 切换后的新 baseline。测试并不代表所有 Harness、操作系统、后台任务投影或移动真机能力已验收。更新运行中的 Desktop 必须另行安排,源码通过验证不等于旧进程已启用新通道。 diff --git a/docs/index.md b/docs/index.md index 21df4d1d7..06467bd30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ | [`architecture/harness-executable-discovery.md`](architecture/harness-executable-discovery.md) | Harness CLI 的跨平台发现、连接页安装指引和 DSH 特殊连接范围;修改安装指引、发现或启动逻辑时阅读。 | | [`architecture/harness-session-import.md`](architecture/harness-session-import.md) | Claude Code、Pi、Hermes、DSH 与 Cursor ACP 本地会话导入契约和恢复边界;扩展导入能力时阅读。 | | [`architecture/external-thread-steering.md`](architecture/external-thread-steering.md) | 外部 Thread 取消旧 Turn 后启动新 Turn 的“调整方向”语义;修改 steering 时阅读。 | +| [`architecture/host-client-channel.md`](architecture/host-client-channel.md) | GUI、Web 与 App 共用 Host 的认证客户端通道、事件驱动快照、交互仲裁及恢复边界。 | | [`architecture/thread-watch.md`](architecture/thread-watch.md) | 一次性 Thread 停下通知(`thread watch`)的模型、结果、送达与边界;修改委派跟进或通知时阅读。 | | [`architecture/official-traffic-ownership.md`](architecture/official-traffic-ownership.md) | Host 只截获自身流量、其余原样转发官方的归属判别与例外;修改请求路由或参数校验时阅读。 | | [`architecture/app-server-transport.md`](architecture/app-server-transport.md) | 原生大历史响应的 WebSocket 与 JSONL 传输边界;排查任务加载、消息大小和转发性能时阅读。 | diff --git a/docs/product/standalone-web.md b/docs/product/standalone-web.md index fd3cfef63..747d82f4e 100644 --- a/docs/product/standalone-web.md +++ b/docs/product/standalone-web.md @@ -24,25 +24,27 @@ npm run start:web -- --port 3181 ## 共享 CH 会话来源 -默认启动查询现有 Desktop 中的版本化 `__codexhostHostRoutingV1`,通过已安装的本机 CDP 控制通道访问 `local` Host 的现有请求接口,不重新注入 Renderer、不修改 CH、不启动/重启 Desktop。自动发现只读取现有 Codex 进程的调试端口参数;也可传 `--ch-cdp http://127.0.0.1:`。Windows 当前要求显式端点。Host 离线时报告不可用,不自动转为独立模式。 +默认优先发现本地 CH 的认证客户端通道 v1,直接连接同一个执行所有者;Web 不启动/重启 Desktop、不重新注入 Renderer。通道由更新后的 Launcher 本地 Host 提供,描述文件默认位于 `~/.codexhost/client-hosts`;自定义 Host 数据目录可传 `--ch-control-directory /client-hosts`。旧 Host 没有此通道时兼容既有 CDP 轮询。单独指定 `--ch-cdp http://127.0.0.1:` 表示显式使用旧入口;若同时指定 control directory,则 CDP 仅用于项目元数据。项目元数据仍通过现有 `__codexhostHostRoutingV1` 只读读取;Windows 仍需提供该 CDP 端点。已选中的事件通道断线后只重连原通道,不暗中改用另一执行所有者或独立模式。仅重建 Web 不会升级正在运行的 CH;启用新通道需要另行更新并启动对应 Host。 -只展示 `modelProvider: codexhost` 且 Host 确认属于 external 的 Threads,包括已归档记录;不接入官方 Codex Threads或远程 Hosts。会话身份保持 CH Thread ID;列表、完整历史、模型和权限均从同一个 Host 查询。Web 不新增会话索引、原生引用映射或历史文件,DSH 格式只作可丢弃的内存展示缓冲。列表按普通刷新/定时查询更新,打开的历史按快照查询更新,没有新增持久同步队列或重放协议。首次目录通过 `session/list` 一次性返回;后续每 10 秒查询只广播实际新增、修改、移除的条目,不将未变化的全部 Threads 重发为 `api-session/added`。比较覆盖标题、执行目录、更新时间、运行状态、父 Thread 和 Harness 归属;历史/模型/权限投影仍走独立控制流。快照查询失败不推进已发布目录基线,普通列表请求与定时刷新共用同一差量发布路径,避免会话数量较多时事件洪泛阻塞切换与历史帧处理。 +只展示 `modelProvider: codexhost` 且 Host 确认属于 external 的 Threads,包括已归档记录;不接入官方 Codex Threads或远程 Hosts。会话身份保持 CH Thread ID;列表、完整历史、模型和权限均从同一个 Host 查询。Web 不新增会话索引、原生引用映射或历史文件,DSH 格式只作可丢弃的内存展示缓冲。事件模式按 Host 通知刷新正在订阅的历史,100ms 合并同一 Thread 的连续变化;普通历史 1.5 秒轮询不再运行。通知只表示状态失效,客户端读取最近 5 轮的权威完整状态,不直接拼接重放的 token。读取期间的新事件会安排下一次校验,不会被正在进行的读取吞掉。目录变化也合并处理;保留每 10 秒的目录/GUI 项目元数据查询,因为项目归属不属于聊天事件。首次目录通过 `session/list` 一次性返回;后续查询只广播实际新增、修改、移除的条目,不将未变化的全部 Threads 重发为 `api-session/added`。比较覆盖标题、执行目录、更新时间、运行状态、父 Thread 和 Harness 归属;历史/模型/权限投影仍走独立控制流。快照查询失败不推进已发布目录基线,普通列表请求与定时刷新共用同一差量发布路径,避免会话数量较多时事件洪泛阻塞切换与历史帧处理。 ### 历史首屏与按需分页 -按已核实的 Codex 本地分页路径,首次打开先通过 `thread/read(includeTurns: false)` 读取元数据,再请求 `thread/turns/list` 的最新 **5 轮**(`sortDirection: "desc"`、`itemsView: "full"`),按时间顺序显示;不等待全部历史,不调用 `thread/resume`,也不把工具/推理内容裁成摘要。Thread 归属由列表的 ownership 查询确认,运行态检查、模型与权限配置异步补入,不阻塞正文。 +首屏遵循已核实的 Codex 本地最近 **5 轮 full/desc** 路径,按时间顺序显示。事件模式由 Host 快照一次提供元数据、最近页、生效配置和待处理交互;冷历史沿用 Host 的 `thread/turns/list`,热会话从同一投影器取得包含用户输入、Reasoning、工具进展的完整状态,而不是 Desktop 专用的精简 `pendingTurn`。旧入口先读 `thread/read(includeTurns: false)` 再读最近页。两种模式均不等待全部历史、不调用 `thread/resume`,也不把工具/推理裁成摘要。Harness 能力目录异步补入,不阻塞正文。 首屏 snapshot 发出后,仅后台预取 **1 页**较早历史,不自动全量遍历。用户向历史顶部滚动、接近顶部 240px 时自动通过 CH 原生 cursor 补一页,无须点击“加载更早”;滚轮、触摸上翻以及真实向顶部的滚动共享同一分页入口。短页即使尚不足一屏,也能由向更早方向的触摸/滚轮意图触发。初次打开、布局变化或程序恢复滚动位置不会自动遍历旧历史;嵌套工具滚动区和输入框不抢占外层分页。加载中显示轻量状态,复用原有阅读锚点;同一边界不重复请求。失败保留已显示内容并提供“重试加载”,继续滚动不会形成自动重试循环。前台与预取共用在途请求,失败不推进 cursor。头部刷新与旧页请求独立执行,慢旧页不阻塞新消息。重新打开复用已加载窗口,不因多客户端打开而继续后台遍历。 为兼容现有 Web 事件协议,内存展示日志使用从 `2 ** 40` 起始的非负局部序号/Turn 坐标,向前插入时向小序号扩展、向后追加时递增;它们不是原生 Turn 编号,不持久化、不传给 CH、不代表未加载的消息数。旧页插入不重编号现有消息、工具引用或滚动锚点;范围耗尽明确报错。独立模式持久日志仍从零开始。界面复用原有消息组件、折叠工具详情和分页滚动锚点,不复制 Codex 私有渲染器或状态存储。 -空闲窗口的普通读取最多 30 秒直接复用;列表检测到更新时间/运行状态变化时提前校验。校验先读取轻量元数据;未变化的空闲历史避免重复拉取正文,每 60 秒仍校验一次最近 5 轮,以覆盖 Host 更新时间秒级精度内的变更。运行中读取最近页;如果新增轮数超过 5,继续读到已知尾部,避免中间漏轮。Web 写操作后强制刷新。重复 cursor、跨页重复 Turn、历史回滚导致原尾部消失均报错,不伪造连续性。当前仍为快照查询而非完整 Host 实时事件订阅;同一秒内的外部修改若未改变 Host 更新时间和观察到的运行状态,最迟在后续尾页校验中检测,不保证立即同步。所有历史缓冲仍仅在内存中。 +旧 CDP 入口保留 30 秒空闲复用、60 秒最近页校验及 1.5 秒运行历史查询。事件模式每次打开和重连均读取权威快照,不依赖秒级 `updatedAt` 变化;Host/网关使用有界内存事件窗口、epoch 和递增序号恢复通知。窗口不足或 Host 换代时重新读取,已显示正文保留,连接状态显示“正在重连”。新增轮数超过 5 时继续读到已知尾部,避免漏轮。重复 cursor、跨页重复 Turn、原尾部消失或已显示流式正文被改写仍明确报错,要求重新打开历史,不伪造连续性。所有历史缓冲均仅在内存中;v1 是事件驱动的绝对状态收敛,不是逐 token 差量协议,长工具输出的快照传输仍有优化空间。 新会话在发送前只是浏览器草稿,Harness/模型/权限可选择;首次发送通过 CH `thread/start` 创建并使用其 canonical Thread ID,然后通过 `turn/start` 提交,同样保存到 CH 原有会话目录。草稿切换为 canonical ID 时仅导航仍查看该草稿的浏览器,不跳转其他客户端。创建或发送结果未知时不自动重试写操作。Web 不执行项目创建请求、不设置 `projectId`。 执行 `cwd` 不等于用户选择了项目。Web 只读 GUI 已有的本地项目目录、Thread 项目归属与未选项目标记,按实际项目根目录补齐导航条目和项目名称;已关联项目的 worktree 归到所属项目,实际执行 `cwd` 保持不变。未选项目的会话保留,但其自动生成的目录不单独列为项目;历史临时目录也不凭路径名称猜测项目。Web 明确选择并实际提交的工作区仍保留。项目元数据不可读取时报错,不回退到全部 `cwd`;仍不创建 GUI 项目、不设置 `projectId`、不执行 `mkdir`。复用已有 `workspaces.json`,旧版本误生成的导航条目仅在共享视图隐藏,不删除记录、会话或物理目录。已经失效的真实项目目录仍能显示历史入口。现有独立模式的历史文件保持原样,不自动迁入共享来源。 -当前共享入口保留文本发送、停止、原生模型/权限选择及改名;需要审批的请求由现有 GUI 处理。Web 未接入共享 Host 的审批响应/实时通知、Steering 和完整子会话操作,不把这些入口包装成已经完成的实时双端同步。独立模式原有功能不受此限制。 +共享入口保留文本发送、停止、原生模型/权限选择及改名。事件模式下,同一 Thread 的 GUI/Web 消息、普通工具进展、生效配置、审批和提问共用 Host 状态。Web 使用原生选项呈现交互,Host 校验并接受第一份有效响应,再通知其余端关闭;断线时撤下交互,重连只恢复仍待处理的请求,不因取消本地卡片而自动回答 Harness。Web/App 发出的用户输入也通过既有 Host 投影广播给 GUI,原生 `clientId` 用于撤下 Web 的乐观回显。已确认发送不会因随后读取失败变成“发送被拒绝”;连接中断导致结果未知时不自动重发。`turn/start` 的请求 ID 去重仅覆盖当前 Host 进程最近 256 条回执,不承诺跨重启或超窗口 exactly-once。同时发送遵循原有 Harness/Host 忙碌规则,不另开会话并行执行。 + +通用 App 可通过 Web 原有认证访问 `/api/ch/v1/{events,rpc,snapshot,respond}`,不需要理解 DSH 渲染日志,也拿不到 Host 私有 token。协议、恢复顺序和部署边界见[客户端通道](../architecture/host-client-channel.md)。本地草稿、滚动位置和当前选中会话不共享。旧 CDP 模式的审批仍在 GUI 处理;共享 Web 的附件、Steering、完整子会话/后台任务投影仍有边界。远程/shared-socket Host 通道、原生 App、真机后台与推送生命周期不在本次接入内。 ## 会话侧栏 @@ -72,7 +74,7 @@ Composer 的模型/推理等级控件旁提供独立的 Harness 图标按钮。 权限菜单及 `/permission` 读取当前会话的 Adapter 权限目录,保留原生模式 ID、标签、描述与危险标记;不再把 `auto` 解释成 DSH 的 Auto review,也不使用进程全局权限目录为不同 Harness 套用相同预设。危险模式有显式确认;无原生可选权限能力的 Harness 隐藏入口。`atCreate` 模式仅在原生会话创建前可选,之后锁定;原生拒绝显示错误,不伪造切换成功。未打开原生会话时保存所选 ID并在 `Adapter.open` 时传入,已有会话通过 `permissionMode.select` 下发。跨 Harness 的草稿切换恢复目标 Harness 原生默认值,不继承同名模式的语义。全局默认权限设置入口不再显示,因为各 Harness 的模式与生效范围并不相同。 -左侧会话行读取服务端持久元数据中的绑定 Harness(草稿读取该会话的已选 Harness),显示插件品牌图标,不从当前 Composer 或其他会话的默认选择推测。未打开的历史会话同样显示,刷新可恢复;运行、审批和未读状态叠加在图标旁,归档保留身份。缺少图标资源时使用通用图标,不伪造其他品牌。 +左侧会话行读取服务端持久元数据中的绑定 Harness(草稿读取该会话的已选 Harness),显示插件品牌图标,不从当前 Composer 或其他会话的默认选择推测。未打开的历史会话同样显示,刷新可恢复;运行、审批和未读状态叠加在图标旁,归档保留身份。侧栏与 Harness 选择器通过同一展示组件消费 CH 插件的 `iconStyle`:优先绘制经过公共契约校验的矢量路径,`currentColor` 随主题变化;位图保留插件声明的背景、圆角和留白比例。不直接把固定黑色、带原始留白的 SVG 当成矢量展示,也不对 Pi 单独反色。展示元数据按目录共享读取,不逐会话请求。缺少图标资源或元数据读取失败时使用通用图标,不伪造其他品牌。 ## 并发运行与预览 diff --git a/package-lock.json b/package-lock.json index 91cef085b..e876692e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1023,6 +1023,7 @@ "version": "0.2.1-alpha.1", "license": "MIT", "dependencies": { + "@codexhost/shared-contracts": "*" , "@deepseek-ai/dsh-client-store": "*", "@deepseek-ai/dsh-util-code-language": "*", "@deepseek-ai/dsh-util-workspace-path": "*", diff --git a/packages/desktop-control/src/host-client-channel.ts b/packages/desktop-control/src/host-client-channel.ts new file mode 100644 index 000000000..ae478bec0 --- /dev/null +++ b/packages/desktop-control/src/host-client-channel.ts @@ -0,0 +1,257 @@ +import { readdir, open } from "node:fs/promises"; +import { constants } from "node:fs"; +import path from "node:path"; +import { + clientChannelDescriptorSchema, + clientChannelEventSchema, + clientThreadSnapshotSchema, + type ClientChannelDescriptor, + type ClientChannelCursor, + type ClientChannelEvent, + type ClientChannelResponse, + type ClientThreadSnapshot, +} from "@codexhost/shared-contracts"; + +export type HostClientUpdate = ClientChannelEvent | { type: "connection"; online: boolean }; +export async function discoverHostClientChannel( + directory: string, +): Promise { + const candidates: ClientChannelDescriptor[] = []; + for (const name of await readdir(directory).catch(() => [])) { + if (!/^host-\d+(?:-[0-9a-f-]+)?\.json$/u.test(name)) continue; + try { + const handle = await open( + path.join(directory, name), + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + try { + const stat = await handle.stat(); + if ( + !stat.isFile() || + stat.size > 4096 || + (process.platform !== "win32" && + (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)) + ) + continue; + const descriptor = clientChannelDescriptorSchema.parse( + JSON.parse(await handle.readFile("utf8")), + ); + process.kill(descriptor.pid, 0); + candidates.push(descriptor); + } finally { + await handle.close(); + } + } catch { + /* Dead, foreign, malformed or inaccessible endpoints are not owners. */ + } + } + return candidates.sort((a, b) => b.startedAt - a.startedAt)[0] ?? null; +} + +/** Read-only reconnects and bounded invalidation replay. Mutations are never retried. */ +export class HostClientChannel { + private descriptor: ClientChannelDescriptor; + private cursor: ClientChannelCursor | undefined; + private history: Array> = []; + private controller: AbortController | undefined; + private retry: ReturnType | undefined; + private listeners = new Set<(update: HostClientUpdate) => void>(); + private stopped = false; + private online = false; + private generation = 0; + private readonly ready = Promise.withResolvers(); + private started = false; + constructor( + private readonly directory: string, + descriptor: ClientChannelDescriptor, + ) { + this.descriptor = descriptor; + } + subscribe(listener: (update: HostClientUpdate) => void, after?: ClientChannelCursor): () => void { + listener({ type: "connection", online: this.online }); + const cursor = this.cursor; + if (this.online && cursor) { + const oldest = this.history[0]?.cursor.sequence ?? cursor.sequence + 1; + const replay = + after?.epoch === cursor.epoch && + after.sequence >= oldest - 1 && + after.sequence <= cursor.sequence; + listener({ type: "hello", version: 1, cursor, reset: !replay }); + if (replay) + for (const event of this.history) + if (event.cursor.sequence > after.sequence) listener(event); + } + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + private publish(update: HostClientUpdate) { + for (const listener of this.listeners) { + try { + listener(update); + } catch { + this.listeners.delete(listener); + } + } + } + start(): Promise { + if (!this.started) { + this.started = true; + void this.connect(); + } + return this.ready.promise; + } + private async connect(): Promise { + if (this.stopped) return; + const controller = new AbortController(); + this.controller = controller; + this.generation++; + let deadline: ReturnType | undefined = setTimeout( + () => controller.abort(), + 5000, + ); + try { + const descriptor = await discoverHostClientChannel(this.directory); + if (!descriptor) throw new Error("Host unavailable"); + this.descriptor = descriptor; + const url = new URL(`http://127.0.0.1:${descriptor.port}/v1/events`); + if (this.cursor) { + url.searchParams.set("epoch", this.cursor.epoch); + url.searchParams.set("after", String(this.cursor.sequence)); + } + const response = await fetch(url, { + redirect: "error", + headers: { authorization: `Bearer ${descriptor.token}` }, + signal: controller.signal, + }); + clearTimeout(deadline); + deadline = setTimeout(() => controller.abort(), 45_000); + if (!response.ok || !response.body) throw new Error("Host event channel refused"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const chunk = await reader.read(); + if (chunk.done) throw new Error("Host event channel ended"); + clearTimeout(deadline); + deadline = setTimeout(() => controller.abort(), 45_000); + buffer += decoder.decode(chunk.value, { stream: true }); + if (buffer.length > 256 * 1024) throw new Error("Host event frame exceeds limit"); + let end: number; + while ((end = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + const event = clientChannelEventSchema.parse(JSON.parse(line)); + if (event.type === "hello") { + if (event.cursor.epoch !== descriptor.epoch) + throw new Error("Host descriptor generation changed"); + if (event.reset || !this.cursor) { + this.cursor = event.cursor; + this.history = []; + } + this.online = true; + this.ready.resolve(undefined); + this.publish({ type: "connection", online: true }); + this.publish(event); + } else { + if ( + !this.cursor || + event.cursor.epoch !== this.cursor.epoch || + event.cursor.sequence !== this.cursor.sequence + 1 + ) + throw new Error("Host event sequence gap"); + this.cursor = event.cursor; + this.history.push(event); + if (this.history.length > 512) this.history.shift(); + this.publish(event); + } + } + } + } catch { + this.ready.reject(new Error("Host event connection unavailable")); + if (!this.stopped) { + this.online = false; + this.publish({ type: "connection", online: false }); + } + } finally { + clearTimeout(deadline); + controller.abort(); + if (!this.stopped) { + this.retry = setTimeout(() => { + void this.connect(); + }, 1000); + this.retry.unref(); + } + } + } + private async post(route: string, body: unknown): Promise { + if (this.stopped || !this.online) + throw new Error("CH is disconnected; reconnect before submitting an operation"); + let response: Response; + let value: { result?: T; error?: { code?: number; message?: string } }; + try { + response = await fetch(`http://127.0.0.1:${this.descriptor.port}${route}`, { + redirect: "error", + method: "POST", + headers: { + authorization: `Bearer ${this.descriptor.token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }); + const parsed: unknown = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error("Invalid Host reply"); + value = parsed as typeof value; + } catch { + throw Object.assign( + new Error( + "CH connection lost; operation outcome may be unknown. Do not retry automatically.", + ), + { code: -32093 }, + ); + } + if (value.error || !response.ok) + throw Object.assign(new Error(value.error?.message ?? "Host request failed"), { + code: value.error?.code ?? -32603, + }); + if (!Object.hasOwn(value, "result")) + throw new Error("Invalid Host reply; operation outcome may be unknown"); + return value.result as T; + } + request(method: string, params: Record): Promise { + return this.post("/v1/rpc", { method, params }); + } + async snapshot(threadId: string): Promise { + const generation = this.generation; + const snapshot = clientThreadSnapshotSchema.parse( + await this.post("/v1/snapshot", { threadId }), + ); + if ( + snapshot.thread.id !== threadId || + snapshot.interactions.some((request) => request.threadId !== threadId) + ) + throw new Error("Host snapshot belongs to another Thread"); + if ( + generation !== this.generation || + !this.online || + snapshot.cursor.epoch !== this.descriptor.epoch + ) + throw Object.assign(new Error("Obsolete Host snapshot; resynchronize after reconnecting"), { + code: -32094, + }); + return snapshot; + } + respond(input: ClientChannelResponse): Promise<{ resolved: boolean }> { + return this.post("/v1/respond", input); + } + close(): void { + this.stopped = true; + this.online = false; + clearTimeout(this.retry); + this.controller?.abort(); + this.publish({ type: "connection", online: false }); + this.listeners.clear(); + } +} diff --git a/packages/desktop-control/src/index.ts b/packages/desktop-control/src/index.ts index cb842a2a6..73e223003 100644 --- a/packages/desktop-control/src/index.ts +++ b/packages/desktop-control/src/index.ts @@ -1,5 +1,10 @@ import { WORKSPACE_CONTRACT_VERSION } from "@codexhost/shared-contracts"; export { createLocalPageOpener } from "./local-page-control.js"; +export { + HostClientChannel, + discoverHostClientChannel, + type HostClientUpdate, +} from "./host-client-channel.js"; export { CdpClient, diff --git a/packages/host-runtime/src/app-server-host.ts b/packages/host-runtime/src/app-server-host.ts index 683ca3219..16e0bfb48 100644 --- a/packages/host-runtime/src/app-server-host.ts +++ b/packages/host-runtime/src/app-server-host.ts @@ -232,6 +232,15 @@ import type { HostConsoleOpener } from "./console-opener.js"; const CONSOLE_REQUEST_TIMEOUT_MS = 120_000; +import { ClientChannelEvents } from "./client-channel-events.js"; +import { ClientCommandReceipts } from "./client-command-receipts.js"; +import { + CLIENT_CHANNEL_METHODS, + clientThreadSnapshotSchema, + type ClientThreadSnapshot, + type ClientChannelResponse, +} from "@codexhost/shared-contracts"; + export type ConsoleHostReply = { result: JsonValue } | { error: { code: number; message: string } }; const SUBAGENT_TERMINAL_REFRESH_DELAYS_MS = [0, 50, 100, 150] as const; @@ -653,8 +662,12 @@ export class AppServerHost { #nextOfficialServerRequestId = 0; #sectionMoves: Promise = Promise.resolve(); #writer: OrderedWriter; + readonly clientEvents = new ClientChannelEvents(); + readonly #clientOwnedThreads = new Set(); + readonly #clientReceipts = new ClientCommandReceipts(); /** Console requests share Desktop handling; their replies return to the console. */ readonly #consoleRequestPrefix = `codexhost-console:${randomUUID()}:`; + readonly #clientRequestPrefix = `${this.#consoleRequestPrefix}client:`; readonly #consoleReplies = new Map void>(); #nextConsoleRequest = 0; #subagentThreadStatuses = new Map(); @@ -687,6 +700,20 @@ export class AppServerHost { ); this.#writer = new OrderedWriter(this.#options.desktopOutput, (value) => { this.#noteDesktopReply(value); + if (isRecord(value) && typeof value.method === "string" && isRecord(value.params)) { + const params = value.params; + const thread = isRecord(params.thread) ? params.thread : undefined; + const id = typeof params.threadId === "string" ? params.threadId : thread?.id; + if ( + typeof id === "string" && + (this.#clientOwnedThreads.has(id) || + thread?.modelProvider === "codexhost" || + this.#externalRuntime?.get(id)) + ) { + this.#clientOwnedThreads.add(id); + this.clientEvents.changed(id, value.method); + } + } return this.#takeConsoleReply(value); }); const environment = this.#options.environment ?? process.env; @@ -1080,7 +1107,7 @@ export class AppServerHost { #takeConsoleReply(value: JsonValue): boolean { if (!isRecord(value) || typeof value.id !== "string") return false; const reply = this.#consoleReplies.get(value.id); - if (!reply) return false; + if (!reply) return value.id.startsWith(this.#consoleRequestPrefix); this.#consoleReplies.delete(value.id); reply(value); return true; @@ -1105,8 +1132,19 @@ export class AppServerHost { return requestDesktopRemoteConnections(this.#options.environment ?? process.env, params); } if (method === REMOTE_SSH_SETUP_METHOD) timeoutMs = Math.max(timeoutMs, 330_000); + return this.#handleControlRequest(method, params, timeoutMs); + } + + async #handleControlRequest( + method: string, + params: unknown, + timeoutMs = CONSOLE_REQUEST_TIMEOUT_MS, + origin: "console" | "client" = "console", + ): Promise { + if (this.#closeRequested || this.#desktopInputEnded) + return { error: { code: -32090, message: "Codex Desktop is closing" } }; const parsed = jsonRpcRequestSchema.safeParse({ - id: `${this.#consoleRequestPrefix}${++this.#nextConsoleRequest}`, + id: `${origin === "client" ? this.#clientRequestPrefix : this.#consoleRequestPrefix}${++this.#nextConsoleRequest}`, method, params: params ?? {}, }); @@ -1144,6 +1182,151 @@ export class AppServerHost { } } + /** Public client operations stay on this owner and never fall through to official Codex. */ + async handleClientRequest(method: string, input: unknown): Promise { + if (!(CLIENT_CHANNEL_METHODS as readonly string[]).includes(method)) + return { error: { code: -32601, message: "Client method is not available" } }; + const parsed = jsonRpcRequestSchema.safeParse({ id: "client", method, params: input ?? {} }); + if (!parsed.success || !isRecord(parsed.data.params)) + return { error: { code: -32602, message: "Invalid client parameters" } }; + const params = parsed.data.params as JsonObject; + if (method === "thread/start") { + const route = classifyCreateRequestRoute(parsed.data); + if (!route || route.selectedHarness === "codex") + return { + error: { + code: -32078, + message: "Only external Harness Threads are available to this client", + }, + }; + } + if (typeof params.threadId === "string") { + const location = await this.#locateExternalThread(params.threadId); + if (location.kind !== "external") + return { error: { code: -32078, message: "Thread is not owned by this external Host" } }; + } else if ( + (method.startsWith("thread/") || method.startsWith("turn/")) && + method !== "thread/list" && + method !== "thread/start" + ) { + return { error: { code: -32602, message: "threadId is required" } }; + } + const submit = async (): Promise => { + const reply = await this.#handleControlRequest( + method, + method === "thread/list" ? { ...params, modelProviders: ["codexhost"] } : params, + CONSOLE_REQUEST_TIMEOUT_MS, + "client", + ); + return "error" in reply && [-32090, -32093].includes(reply.error.code) + ? { + error: { + code: reply.error.code, + message: + "CH did not confirm an outcome; the operation may have executed. Do not retry automatically.", + }, + } + : reply; + }; + return method === "turn/start" && typeof params.clientUserMessageId === "string" + ? this.#clientReceipts.run( + JSON.stringify([params.threadId, params.clientUserMessageId]), + params, + submit, + ) + : submit(); + } + + /** Absolute, bounded state; capture fields and delivery cursor without yielding. + * Notifications are invalidations, not text deltas to replay over this snapshot. + */ + async clientSnapshot(threadId: string): Promise { + let thread = this.#externalRuntime.get(threadId); + if (!thread) { + const prepared = await this.handleClientRequest("thread/turns/list", { + threadId, + limit: 5, + sortDirection: "desc", + itemsView: "full", + }); + if ("error" in prepared) throw new Error(prepared.error.message); + thread = this.#externalRuntime.get(threadId); + } + if (!thread) throw new Error("Thread is not owned by this local Host"); + const interactions = [ + ...[...this.#pendingDesktopApprovals] + .filter(([, pending]) => pending.thread === thread) + .map(([requestId, pending]) => ({ + requestId, + threadId, + kind: "approval", + interaction: pending.interaction, + request: pending.projection.request, + })), + ...[...this.#pendingDesktopQuestions] + .filter(([, pending]) => pending.thread === thread) + .map(([requestId, pending]) => ({ + requestId, + threadId, + kind: "question", + interaction: pending.interaction, + request: pending.projection.request, + })), + ]; + const state = thread.stateObserver.state; + const snapshot = clientThreadSnapshotSchema.parse( + JSON.parse( + JSON.stringify({ + cursor: this.clientEvents.cursor, + thread: { ...thread.thread, turns: [] }, + turnsPage: listExternalTurns(this.#externalHistoryTurns(thread, true), { + limit: 5, + sortDirection: "desc", + itemsView: "full", + }), + configuration: { + effectiveModel: state.effectiveModel, + effectiveThinkingOptionId: state.effectiveThinkingOptionId, + effectivePermissionModeId: state.effectivePermissionModeId, + }, + interactions, + }), + ), + ); + // Native history may be refreshed after terminal output. Preserve this + // process's submission receipt, without persisting a second message index. + for (const turn of snapshot.turnsPage.data) { + if (typeof turn.id !== "string" || !Array.isArray(turn.items)) continue; + const clientId = this.#clientReceipts.clientIdForTurn(threadId, turn.id); + const user = turn.items.find( + (item: unknown) => isRecord(item) && item.type === "userMessage", + ) as Record | undefined; + if (clientId && user && typeof user.clientId !== "string") user.clientId = clientId; + } + return snapshot; + } + + async respondClient(input: ClientChannelResponse): Promise<{ resolved: boolean }> { + if (input.epoch !== this.clientEvents.epoch) + throw new Error("Host generation changed; refresh the pending interaction"); + const id = input.requestId; + const approval = isHostApprovalRequestId(id) + ? this.#pendingDesktopApprovals.get(id) + : undefined; + const question = isHostQuestionRequestId(id) + ? this.#pendingDesktopQuestions.get(id) + : undefined; + const pending = approval ?? question; + if (!pending) return { resolved: true }; + if (pending.thread.id !== input.threadId) + throw new Error("Interaction does not belong to this Thread"); + pending.projection.parseResponse(input.result); // Reject malformed client responses before claiming. + const response = jsonValueSchema.parse({ id, result: input.result }); + if (approval) await this.#handleDesktopApprovalResponse(response); + else await this.#handleDesktopQuestionResponse(response); + return { resolved: true }; + } + #forgetPendingOfficialTurnStarts(threadId: string): void { for (const [requestId, pendingThreadId] of this.#pendingOfficialTurnStarts) { if (pendingThreadId === threadId) this.#pendingOfficialTurnStarts.delete(requestId); @@ -4074,8 +4257,10 @@ export class AppServerHost { return this.#externalRuntime.register(input); } - #locateExternalThread(threadId: string): Promise { - return this.#externalRuntime.locate(threadId); + async #locateExternalThread(threadId: string): Promise { + const location = await this.#externalRuntime.locate(threadId); + if (location.kind === "external") this.#clientOwnedThreads.add(threadId); + return location; } async #resolveExternalThread(threadId: string): Promise { @@ -4465,11 +4650,14 @@ export class AppServerHost { } } - #externalHistoryTurns(thread: ExternalThread): JsonObject[] { + #externalHistoryTurns(thread: ExternalThread, forClient = false): JsonObject[] { const turns = this.#withOpenBackgroundCommands(thread, thread.turns); - if (!thread.activeTurnId) return turns; + if (!thread.activeTurnId || (forClient && thread.ephemeralTurnIds.has(thread.activeTurnId))) + return turns; const active = thread.projectedTurns.get(thread.activeTurnId); - return active ? [...turns, active.projector.pendingTurn()] : turns; + return active + ? [...turns, forClient ? active.projector.snapshotTurn() : active.projector.pendingTurn()] + : turns; } /** @@ -4579,6 +4767,7 @@ export class AppServerHost { text, undefined, typeof params.clientUserMessageId === "string" ? params.clientUserMessageId : undefined, + String(request.id).startsWith(this.#clientRequestPrefix), ); try { await this.#writer.json(rpcEnvelope(request, { result: { turn: started.turn } })); @@ -4611,6 +4800,7 @@ export class AppServerHost { typeof requestObject(request).clientUserMessageId === "string" ? (requestObject(request).clientUserMessageId as string) : undefined, + String(request.id).startsWith(this.#clientRequestPrefix), ), ); try { @@ -4639,12 +4829,13 @@ export class AppServerHost { inputText: string, assertActive?: () => void, clientUserMessageId?: string, + publishInput = false, ): Promise<{ turnId: HostTurnId; turn: JsonObject; gate: TurnProjectionGate }> { const text = restoreHarnessCommandMentions(inputText); const commands = thread.session.commands; if (!commands || !isExternalCommandCandidate(text)) { assertActive?.(); - return this.#beginExternalTurn(thread, text, clientUserMessageId); + return this.#beginExternalTurn(thread, text, clientUserMessageId, publishInput); } this.#pendingExternalCommandRequests.add(thread.id); try { @@ -4657,7 +4848,7 @@ export class AppServerHost { assertActive?.(); if (!command) { this.#pendingExternalCommandRequests.delete(thread.id); - return await this.#beginExternalTurn(thread, text, clientUserMessageId); + return await this.#beginExternalTurn(thread, text, clientUserMessageId, publishInput); } return await this.#beginExternalCommand(thread, command.descriptor, command.arguments); } catch (error) { @@ -4676,6 +4867,7 @@ export class AppServerHost { thread: ExternalThread, text: string, clientUserMessageId?: string, + publishInput = false, ): Promise<{ turnId: HostTurnId; turn: JsonObject; @@ -4699,11 +4891,10 @@ export class AppServerHost { turnId, cwd: thread.cwd, startedAtMs, - ...(this.#options.externalOnly - ? { - initialInput: [{ type: "text" as const, text }], - ...(clientUserMessageId ? { clientUserMessageId } : {}), - } + snapshotInput: [{ type: "text", text }], + ...(clientUserMessageId ? { clientUserMessageId } : {}), + ...(this.#options.externalOnly || publishInput + ? { initialInput: [{ type: "text" as const, text }] } : {}), }), }; @@ -4906,6 +5097,7 @@ export class AppServerHost { } } thread.stateObserver.update(event.state); + this.clientEvents.changed(thread.id, "codexhost/thread/configuration/updated"); } catch (error) { thread.persistenceError = error instanceof Error ? error : new Error(errorMessage(error)); thread.stateObserver.fault(thread.persistenceError); @@ -5062,7 +5254,7 @@ export class AppServerHost { thread.ephemeralTurnIds.delete(event.turnId); this.#manualCompactionTurns.delete(thread); } else { - thread.turns.push(result.completedTurn); + thread.turns.push(projection.projector.snapshotTurn(result.completedTurn)); thread.projectedTerminalTurnId = event.turnId; thread.thread.updatedAt = completedAt; thread.thread.recencyAt = completedAt; @@ -5330,9 +5522,8 @@ export class AppServerHost { this.#externalRuntime.idleRelease.failure(pending.thread) ) return true; - if (this.#options.externalOnly) - await this.#resolveDesktopApproval(pending.interaction.interactionId); - else this.#pendingDesktopApprovals.delete(requestId); + if (this.#pendingDesktopApprovals.get(requestId) !== pending) return true; + await this.#resolveDesktopApproval(pending.interaction.interactionId); let response: HostApprovalResponse; try { @@ -5480,9 +5671,8 @@ export class AppServerHost { this.#externalRuntime.idleRelease.failure(pending.thread) ) return true; - if (this.#options.externalOnly) - await this.#resolveDesktopQuestion(pending.interaction.interactionId); - else this.#pendingDesktopQuestions.delete(requestId); + if (this.#pendingDesktopQuestions.get(requestId) !== pending) return true; + await this.#resolveDesktopQuestion(pending.interaction.interactionId); if (pending.timeout) clearTimeout(pending.timeout); let response; @@ -5511,6 +5701,7 @@ export class AppServerHost { const pending = this.#pendingDesktopQuestions.get(requestId); if (!pending) return; await this.#externalRuntime.idleRelease.runOperation(pending.thread.id, async () => { + if (this.#pendingDesktopQuestions.get(requestId) !== pending) return; if ( this.#externalRuntime.get(pending.thread.id) !== pending.thread || this.#externalRuntime.idleRelease.failure(pending.thread) diff --git a/packages/host-runtime/src/client-channel-events.ts b/packages/host-runtime/src/client-channel-events.ts new file mode 100644 index 000000000..3553909cc --- /dev/null +++ b/packages/host-runtime/src/client-channel-events.ts @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import type { ClientChannelCursor, ClientChannelEvent } from "@codexhost/shared-contracts"; + +/** Bounded, process-local invalidation journal. No transcript or independent + * Session ownership is stored here. Slow listeners detach instead of blocking GUI. */ +export class ClientChannelEvents { + readonly epoch = randomUUID(); + private sequence = 0; + private history: Array> = []; + private listeners = new Set<(event: ClientChannelEvent) => void>(); + constructor(private readonly capacity = 512) {} + get cursor(): ClientChannelCursor { + return { epoch: this.epoch, sequence: this.sequence }; + } + changed(threadId: string, method: string): void { + const event: Extract = { + type: "changed", + cursor: { epoch: this.epoch, sequence: ++this.sequence }, + threadId, + method, + }; + this.history.push(event); + if (this.history.length > this.capacity) this.history.shift(); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + this.listeners.delete(listener); + } + } + } + subscribe( + listener: (event: ClientChannelEvent) => void, + after?: ClientChannelCursor, + ): () => void { + const cursor = this.cursor; + const oldest = this.history[0]?.cursor.sequence ?? cursor.sequence + 1; + const replay = + after?.epoch === cursor.epoch && + after.sequence >= oldest - 1 && + after.sequence <= cursor.sequence; + listener({ type: "hello", version: 1, cursor, reset: !replay }); + if (replay) + for (const event of this.history) if (event.cursor.sequence > after.sequence) listener(event); + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + clear(): void { + this.listeners.clear(); + this.history = []; + } +} diff --git a/packages/host-runtime/src/client-channel-server.ts b/packages/host-runtime/src/client-channel-server.ts new file mode 100644 index 000000000..f3d14d068 --- /dev/null +++ b/packages/host-runtime/src/client-channel-server.ts @@ -0,0 +1,159 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage } from "node:http"; +import { mkdir, writeFile, rename, rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { AddressInfo } from "node:net"; +import { + clientChannelCursorSchema, + clientChannelResponseSchema, + type ClientThreadSnapshot, + type ClientChannelResponse, +} from "@codexhost/shared-contracts"; +import type { ClientChannelEvents } from "./client-channel-events.js"; + +export interface ClientChannelTarget { + readonly clientEvents: ClientChannelEvents; + handleClientRequest(method: string, params: unknown): Promise; + clientSnapshot(threadId: string): Promise; + respondClient(input: ClientChannelResponse): Promise<{ resolved: boolean }>; +} +async function body(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const part of request) { + const buffer = Buffer.from(part as Uint8Array); + bytes += buffer.length; + if (bytes > 1024 * 1024) throw new Error("Client request is too large"); + chunks.push(buffer); + } + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Expected an object"); + return value as Record; +} + +/** Only loopback, authenticated Node peers can reach the owner. Apps connect via + * their authenticated gateway, never receive this private discovery token. */ +export async function startClientChannelServer(options: { + target: ClientChannelTarget; + environment: NodeJS.ProcessEnv; + directory?: string; +}) { + const token = randomBytes(32).toString("hex"); + const server = createServer((request, response) => { + const reply = (status: number, value: unknown) => { + response.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + }); + response.end(JSON.stringify(value)); + }; + void (async () => { + const auth = request.headers.authorization; + const left = Buffer.from(auth?.startsWith("Bearer ") ? auth.slice(7) : ""); + const right = Buffer.from(token); + if (request.headers.origin || left.length !== right.length || !timingSafeEqual(left, right)) { + reply(401, { error: { code: -32001, message: "Unauthorized" } }); + return; + } + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/v1/events") { + const after = url.searchParams.has("epoch") + ? clientChannelCursorSchema.parse({ + epoch: url.searchParams.get("epoch"), + sequence: Number(url.searchParams.get("after")), + }) + : undefined; + response.writeHead(200, { + "content-type": "application/x-ndjson", + "cache-control": "no-store", + }); + const remove = options.target.clientEvents.subscribe((event) => { + if (response.writableLength > 256 * 1024) { + response.destroy(); + throw new Error("Slow client detached"); + } + response.write(JSON.stringify(event) + "\n"); + }, after); + const heartbeat = setInterval(() => response.write("\n"), 15_000); + heartbeat.unref(); + response.on("close", () => { + clearInterval(heartbeat); + remove(); + }); + return; + } + if (request.method !== "POST") { + reply(404, { error: { code: -32601, message: "Not found" } }); + return; + } + const input = await body(request); + if (url.pathname === "/v1/rpc" && typeof input.method === "string") + reply(200, await options.target.handleClientRequest(input.method, input.params)); + else if (url.pathname === "/v1/snapshot" && typeof input.threadId === "string") + reply(200, { result: await options.target.clientSnapshot(input.threadId) }); + else if (url.pathname === "/v1/respond") + reply(200, { + result: await options.target.respondClient(clientChannelResponseSchema.parse(input)), + }); + else reply(404, { error: { code: -32601, message: "Not found" } }); + })().catch((error: unknown) => { + if (!response.headersSent) + reply(400, { + error: { + code: -32602, + message: error instanceof Error ? error.message : "Client operation failed", + }, + }); + else response.destroy(); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const directory = + options.directory ?? + path.join( + options.environment.CODEXHOST_DATA_DIR ?? path.join(homedir(), ".codexhost"), + "client-hosts", + ); + const descriptorPath = path.join( + directory, + `host-${process.pid}-${options.target.clientEvents.epoch}.json`, + ); + const temporary = `${descriptorPath}.${randomBytes(8).toString("hex")}.tmp`; + const descriptor = { + version: 1 as const, + pid: process.pid, + port: (server.address() as AddressInfo).port, + token, + startedAt: Date.now(), + epoch: options.target.clientEvents.epoch, + }; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile(temporary, JSON.stringify(descriptor), { flag: "wx", mode: 0o600 }); + await rename(temporary, descriptorPath); + } catch (error) { + server.closeAllConnections(); + server.close(); + await rm(temporary, { force: true }); + throw error; + } + return { + descriptorPath, + descriptor, + async close() { + await rm(descriptorPath, { force: true }); + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + }, + }; +} diff --git a/packages/host-runtime/src/client-command-receipts.ts b/packages/host-runtime/src/client-command-receipts.ts new file mode 100644 index 000000000..b7f8da909 --- /dev/null +++ b/packages/host-runtime/src/client-command-receipts.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import type { ConsoleHostReply } from "./app-server-host.js"; + +/** Process-local retry receipts, not durable exactly-once execution. In-flight + * entries cannot be evicted; unknown outcomes remain in the bounded window. */ +interface Receipt { + fingerprint: string; + result: Promise; + settled: boolean; + threadId: string | undefined; + clientId: string | undefined; + turnId: string | undefined; +} +export class ClientCommandReceipts { + private entries = new Map(); + clientIdForTurn(threadId: string, turnId: string): string | undefined { + for (const entry of this.entries.values()) + if (entry.threadId === threadId && entry.turnId === turnId) return entry.clientId; + return undefined; + } + run( + key: string, + params: Record, + submit: () => Promise, + ): Promise { + const canonical = JSON.stringify(params, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, (value as Record)[key]]), + ) + : value, + ); + const fingerprint = createHash("sha256").update(canonical).digest("hex"); + const existing = this.entries.get(key); + if (existing) + return existing.fingerprint === fingerprint + ? existing.result + : Promise.resolve({ + error: { code: -32602, message: "Message ID was already used with different input" }, + }); + if (this.entries.size >= 256) { + const oldest = [...this.entries].find(([, entry]) => entry.settled)?.[0]; + if (oldest === undefined) + return Promise.resolve({ + error: { code: -32090, message: "Too many pending client commands" }, + }); + this.entries.delete(oldest); + } + const result = Promise.resolve().then(submit); + const entry: Receipt = { + fingerprint, + result, + settled: false, + threadId: typeof params.threadId === "string" ? params.threadId : undefined, + clientId: + typeof params.clientUserMessageId === "string" ? params.clientUserMessageId : undefined, + turnId: undefined, + }; + this.entries.set(key, entry); + void result.then( + (reply) => { + entry.settled = true; + if ("error" in reply && [-32602, -32072].includes(reply.error.code)) + this.entries.delete(key); // Validation/busy: definitely not submitted. + if ( + "result" in reply && + reply.result && + typeof reply.result === "object" && + !Array.isArray(reply.result) + ) { + const turn = reply.result.turn; + if ( + turn && + typeof turn === "object" && + !Array.isArray(turn) && + typeof turn.id === "string" + ) + entry.turnId = turn.id; + } + }, + () => { + entry.settled = true; + }, + ); + return result; + } +} diff --git a/packages/host-runtime/src/run-host-runtime.ts b/packages/host-runtime/src/run-host-runtime.ts index 810ae6108..ce6032cf2 100644 --- a/packages/host-runtime/src/run-host-runtime.ts +++ b/packages/host-runtime/src/run-host-runtime.ts @@ -25,6 +25,7 @@ import { } from "./delegation-types.js"; import { createProductionExternalThreadStore } from "./external-thread-repository.js"; import { SharedThreadOwner } from "./shared-thread-owner.js"; +import { startClientChannelServer } from "./client-channel-server.js"; import { SharedThreadBridge, connectSharedThreads, @@ -179,9 +180,18 @@ async function runWithConsoleControl( return undefined; }) : undefined; + const clients = enabled + ? await startClientChannelServer({ target: host, environment }).catch((error: unknown) => { + process.stderr.write( + `codexhost Host Runtime: client channel unavailable: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return undefined; + }) + : undefined; try { return await host.run(); } finally { + await clients?.close().catch(() => undefined); await control?.close().catch(() => undefined); } } diff --git a/packages/host-runtime/test/client-channel.test.ts b/packages/host-runtime/test/client-channel.test.ts new file mode 100644 index 000000000..72219291f --- /dev/null +++ b/packages/host-runtime/test/client-channel.test.ts @@ -0,0 +1,362 @@ +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + HostClientChannel, + discoverHostClientChannel, + type HostClientUpdate, +} from "@codexhost/desktop-control"; +import type { ClientChannelEvent } from "@codexhost/shared-contracts"; +import { ClientChannelEvents } from "../src/client-channel-events.js"; +import { ClientCommandReceipts } from "../src/client-command-receipts.js"; +import { startClientChannelServer } from "../src/client-channel-server.js"; +import { + createFixture, + startPiThread, + startPiTurn, + stopFixture, + writeRequest, + requiredMessageId, +} from "./app-server-host-fixture.js"; + +const cleanup: Array<() => void | Promise> = []; +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); +async function setup() { + const fixture = createFixture(); + cleanup.push(() => stopFixture(fixture)); + await fixture.ready; + const directory = mkdtempSync(path.join(tmpdir(), "client-channel-")); + cleanup.push(() => rmSync(directory, { recursive: true, force: true })); + const server = await startClientChannelServer({ + target: fixture.host, + environment: {}, + directory, + }); + cleanup.push(() => server.close()); + const a = new HostClientChannel(directory, server.descriptor), + b = new HostClientChannel(directory, server.descriptor); + cleanup.push(() => { + a.close(); + b.close(); + }); + const events: HostClientUpdate[] = []; + a.subscribe((event) => events.push(event)); + await Promise.all([a.start(), b.start()]); + return { fixture, directory, server, a, b, events }; +} +function current(fixture: ReturnType) { + const session = fixture.adapter.sessions[0]; + if (!session) throw new Error("Missing fake Session"); + return session; +} + +describe("public authenticated owner channel", () => { + it("rejects unauthenticated/browser-origin traffic and official Thread execution", async () => { + const { server, a, fixture } = await setup(); + const url = `http://127.0.0.1:${server.descriptor.port}/v1/rpc`; + expect((await fetch(url, { method: "POST", body: "{}" })).status).toBe(401); + expect( + ( + await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${server.descriptor.token}`, + origin: "http://untrusted.invalid", + }, + body: "{}", + }) + ).status, + ).toBe(401); + if (process.platform !== "win32") + expect(statSync(server.descriptorPath).mode & 0o777).toBe(0o600); + await expect(a.request("thread/start", { model: "gpt-5", cwd: "/synthetic" })).rejects.toThrow( + "Only external", + ); + await expect( + a.request("thread/read", { threadId: "official", includeTurns: false }), + ).rejects.toThrow("not owned"); + await expect(a.request("thread/resume", { threadId: "official" })).rejects.toThrow( + "not available", + ); + expect(fixture.adapter.sessions).toHaveLength(0); + }); + it("streams GUI changes and supplies absolute snapshots without another Adapter", async () => { + const { fixture, a, b, events } = await setup(); + const id = await startPiThread(fixture); + await startPiTurn(fixture, id); + current(fixture).appendText("shared text"); + await vi.waitFor(() => + expect( + events.some( + (event) => event.type === "changed" && event.method === "item/agentMessage/delta", + ), + ).toBe(true), + ); + const first = await a.snapshot(id), + second = await b.snapshot(id); + expect(JSON.stringify(first.turnsPage)).toContain("shared text"); + expect(second.turnsPage).toEqual(first.turnsPage); + expect(JSON.stringify(first.turnsPage)).toContain("synthetic"); + expect(first.cursor.epoch).toBe(second.cursor.epoch); + expect(fixture.adapter.sessions).toHaveLength(1); + current(fixture).succeedTurn(); + }); + it("includes in-progress reasoning and tool output, not Desktop's partial pending-Turn shape", async () => { + const { fixture, a } = await setup(); + const id = await startPiThread(fixture); + await startPiTurn(fixture, id); + current(fixture).startReasoning("thinking snapshot"); + const command = current(fixture).startCommandExecution("echo streamed"); + current(fixture).appendCommandOutput(command, "tool progress"); + await fixture.collector.waitFor( + (value) => value.method === "item/commandExecution/outputDelta", + ); + const snapshot = await a.snapshot(id); + expect(JSON.stringify(snapshot.turnsPage)).toContain("thinking snapshot"); + expect(snapshot.turnsPage.data[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "commandExecution", + aggregatedOutput: "tool progress", + status: "inProgress", + }), + ]), + ); + current(fixture).completeItem(command, { status: "succeeded" }); + await fixture.collector.waitFor( + (value) => + value.method === "item/completed" && + (value.params as { item?: { id?: string } })?.item?.id === command, + ); + expect((await a.snapshot(id)).turnsPage.data[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "commandExecution", status: "completed" }), + ]), + ); + current(fixture).succeedTurn(); + }); + it("publishes GUI model changes and native cancellation to every client", async () => { + const { fixture, a, b, events } = await setup(); + const id = await startPiThread(fixture); + writeRequest(fixture.desktopInput, { + id: 77, + method: "codexhost/thread/model/select", + params: { threadId: id, model: { id: "fake-model-v1.secondary" } }, + }); + await fixture.collector.waitFor((value) => value.id === 77); + await vi.waitFor(() => + expect( + events.some( + (event) => + event.type === "changed" && event.method === "codexhost/thread/configuration/updated", + ), + ).toBe(true), + ); + expect((await a.snapshot(id)).configuration.effectiveModel?.id).toBe("fake-model-v1.secondary"); + const turnId = await startPiTurn(fixture, id); + current(fixture).completeCancellationOnRequest(); + await b.request("turn/interrupt", { threadId: id, turnId }); + await fixture.collector.waitFor((value) => value.method === "turn/completed"); + expect((await a.snapshot(id)).turnsPage.data[0]?.status).toBe("interrupted"); + }); + it("rejects a competing turn instead of allocating a second execution owner", async () => { + const { fixture, a, b } = await setup(); + const id = await startPiThread(fixture); + const results = await Promise.allSettled([ + a.request("turn/start", { + threadId: id, + clientUserMessageId: "a", + input: [{ type: "text", text: "A" }], + }), + b.request("turn/start", { + threadId: id, + clientUserMessageId: "b", + input: [{ type: "text", text: "B" }], + }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(fixture.adapter.sessions).toHaveLength(1); + current(fixture).succeedTurn(); + }); + it.each(["web", "gui"])( + "arbitrates %s approval against another client's simultaneous response", + async (winner) => { + const { fixture, a, b, server } = await setup(); + const id = await startPiThread(fixture); + current(fixture).requestApprovalOnNextTurn("Allow test action?"); + await startPiTurn(fixture, id); + const message = await fixture.collector.waitFor( + (value) => value.method === "mcpServer/elicitation/request", + ); + const snapshot = await a.snapshot(id); + expect(snapshot.interactions).toHaveLength(1); + const request = snapshot.interactions[0]; + if (!request) throw new Error("Missing approval request"); + const response = { + epoch: server.descriptor.epoch, + threadId: id, + requestId: request.requestId, + result: { action: "accept", content: {} }, + }; + await expect(a.respond({ ...response, result: { action: "invalid" } })).rejects.toThrow( + "unsupported action", + ); + expect((await a.snapshot(id)).interactions).toHaveLength(1); + if (winner === "gui") { + writeRequest(fixture.desktopInput, { + id: requiredMessageId(message), + result: response.result, + }); + await fixture.collector.waitFor((value) => value.method === "serverRequest/resolved"); + } + await Promise.all([a.respond(response), b.respond(response)]); + writeRequest(fixture.desktopInput, { + id: requiredMessageId(message), + result: response.result, + }); + await vi.waitFor(() => expect(current(fixture).interactionResponses).toHaveLength(1)); + expect((await b.snapshot(id)).interactions).toHaveLength(0); + expect( + await fixture.collector.waitFor((value) => value.method === "serverRequest/resolved"), + ).toMatchObject({ params: { requestId: message.id, threadId: id } }); + current(fixture).succeedTurn(); + }, + ); + it("restores pending questions on reconnect and validates answers in the owner", async () => { + const { fixture, a, b, directory, server } = await setup(); + const id = await startPiThread(fixture); + current(fixture).askQuestionOnNextTurn({ + type: "choice", + id: "choice", + prompt: "Choose", + options: [{ value: "native-a", label: "A" }], + multiple: false, + allowOther: false, + optional: false, + }); + await startPiTurn(fixture, id); + await fixture.collector.waitFor((value) => value.method === "item/tool/requestUserInput"); + a.close(); + const late = new HostClientChannel(directory, server.descriptor); + cleanup.push(() => late.close()); + await late.start(); + const snapshot = await late.snapshot(id); + expect(snapshot.interactions).toHaveLength(1); + const request = snapshot.interactions[0]; + if (!request) throw new Error("Missing question request"); + const response = { + epoch: snapshot.cursor.epoch, + threadId: id, + requestId: request.requestId, + result: { answers: { choice: { answers: ["A"] } } }, + }; + await Promise.all([late.respond(response), b.respond(response)]); + await vi.waitFor(() => expect(current(fixture).interactionResponses).toHaveLength(1)); + expect(current(fixture).interactionResponses[0]).toMatchObject({ + response: { type: "question", answers: { choice: ["native-a"] } }, + }); + current(fixture).succeedTurn(); + }); + it("deduplicates client message IDs and rejects different text under the same ID", async () => { + const { fixture, a, b } = await setup(); + const id = await startPiThread(fixture); + const execute = vi.spyOn(current(fixture), "execute"); + const params = { + threadId: id, + clientUserMessageId: "same", + input: [{ type: "text", text: "one" }], + }; + const [first, second] = await Promise.all([ + a.request("turn/start", params), + b.request("turn/start", params), + ]); + expect(second).toEqual(first); + await expect( + b.request("turn/start", { ...params, input: [{ type: "text", text: "different" }] }), + ).rejects.toThrow("different input"); + expect(execute).toHaveBeenCalledOnce(); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({ type: "turn.start" })); + const started = await fixture.collector.waitFor((value) => value.method === "turn/started"); + expect(started).toMatchObject({ + params: { + turn: { items: [{ type: "userMessage", clientId: "same", content: [{ text: "one" }] }] }, + }, + }); + current(fixture).succeedTurn(); + await fixture.collector.waitFor((value) => value.method === "turn/completed"); + await a.request("thread/turns/list", { + threadId: id, + limit: 5, + itemsView: "full", + sortDirection: "desc", + }); + expect((await b.snapshot(id)).turnsPage.data[0]?.items).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "userMessage", clientId: "same" })]), + ); + }); + it("rediscovers a restarted owner and emits a new epoch/reset without replaying writes", async () => { + const { fixture, server, directory, a, events } = await setup(); + const oldEpoch = server.descriptor.epoch; + await server.close(); + await vi.waitFor(() => expect(events.at(-1)).toEqual({ type: "connection", online: false })); + await expect(a.request("turn/start", { threadId: "offline" })).rejects.toThrow("disconnected"); + const nextFixture = createFixture(); + cleanup.push(() => stopFixture(nextFixture)); + await nextFixture.ready; + const next = await startClientChannelServer({ + target: nextFixture.host, + environment: {}, + directory, + }); + cleanup.push(() => next.close()); + await vi.waitFor( + () => + expect( + events.some( + (event) => event.type === "hello" && event.reset && event.cursor.epoch !== oldEpoch, + ), + ).toBe(true), + { timeout: 4000 }, + ); + expect((await discoverHostClientChannel(directory))?.epoch).toBe(next.descriptor.epoch); + expect(nextFixture.adapter.sessions).toHaveLength(0); + expect(fixture.adapter.sessions).toHaveLength(0); + }); +}); + +describe("bounded invalidation recovery", () => { + it("replays a retained cursor, resets gaps, future cursors and previous epochs", () => { + const hub = new ClientChannelEvents(2); + hub.changed("a", "item/started"); + const cursor = hub.cursor; + hub.changed("a", "item/completed"); + const events: ClientChannelEvent[] = []; + const stop = hub.subscribe((event) => events.push(event), cursor); + expect(events.map((event) => event.type)).toEqual(["hello", "changed"]); + expect(events[0]).toMatchObject({ reset: false }); + stop(); + hub.changed("b", "turn/started"); + hub.changed("b", "turn/completed"); + for (const after of [ + cursor, + { ...hub.cursor, sequence: 99 }, + { ...hub.cursor, epoch: crypto.randomUUID() }, + ]) { + const reset: ClientChannelEvent[] = []; + hub.subscribe((event) => reset.push(event), after)(); + expect(reset).toHaveLength(1); + expect(reset[0]).toMatchObject({ type: "hello", reset: true }); + } + }); + it("keeps unknown command outcomes and never executes a duplicate", async () => { + const receipts = new ClientCommandReceipts(); + const submit = vi.fn(async () => ({ error: { code: -32090, message: "outcome unknown" } })); + expect(await receipts.run("id", { text: "one", mode: "same" }, submit)).toHaveProperty("error"); + await receipts.run("id", { mode: "same", text: "one" }, submit); + expect(submit).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/protocol-core/src/codex-ui-projector.ts b/packages/protocol-core/src/codex-ui-projector.ts index f4d1d4de8..c1fd38cf3 100644 --- a/packages/protocol-core/src/codex-ui-projector.ts +++ b/packages/protocol-core/src/codex-ui-projector.ts @@ -735,6 +735,7 @@ function diffText(changes: HostFileChange[]): string { export class CodexTurnProjector { readonly #cwd: string; readonly #input: HostTurnSnapshot["input"]; + readonly #snapshotInput: HostTurnSnapshot["input"]; readonly #clientUserMessageId: string | null; readonly #interactions = new Map(); readonly #items = new Map(); @@ -755,12 +756,15 @@ export class CodexTurnProjector { cwd: string; startedAtMs: number; initialInput?: HostTurnSnapshot["input"]; + /** Accepted input for independent viewers, without altering Desktop's optimistic echo. */ + snapshotInput?: HostTurnSnapshot["input"]; clientUserMessageId?: string; }) { this.#threadId = input.threadId; this.#turnId = input.turnId; this.#cwd = input.cwd; this.#input = input.initialInput ?? []; + this.#snapshotInput = input.snapshotInput ?? this.#input; this.#clientUserMessageId = input.clientUserMessageId ?? null; this.#startedAtMs = input.startedAtMs; this.#startedAt = Math.floor(input.startedAtMs / 1000); @@ -815,6 +819,31 @@ export class CodexTurnProjector { }; } + /** Complete materialized state for snapshot clients. pendingTurn() remains + * the Desktop-specific wire shape; it deliberately omits several live Items. + */ + snapshotTurn(base: JsonObject = this.pendingTurn()): JsonObject { + return { + ...base, + items: [ + ...this.#projectInput(this.#snapshotInput), + ...this.#wireItemOrder.flatMap((itemId) => { + const projected = this.#items.get(itemId); + if (!projected?.wireStarted) return []; + return [ + projectItem( + itemId === this.#fileItemId ? this.#fileSummary() : projected.item, + projected.outcome, + this.#cwd, + true, + this.#threadId, + ), + ]; + }), + ], + }; + } + /** * Close a failed output stream using only state already accepted by this projector. * Project these events in order through the normal Host path so pending Desktop @@ -1330,15 +1359,15 @@ export class CodexTurnProjector { }; } - #projectInput(): JsonObject[] { - return this.#input.length === 0 + #projectInput(input = this.#input): JsonObject[] { + return input.length === 0 ? [] : [ { id: `${this.#turnId}-user`, type: "userMessage", clientId: this.#clientUserMessageId, - content: this.#input.map(({ text }) => ({ type: "text", text, text_elements: [] })), + content: input.map(({ text }) => ({ type: "text", text, text_elements: [] })), }, ]; } diff --git a/packages/shared-contracts/src/client-channel.ts b/packages/shared-contracts/src/client-channel.ts new file mode 100644 index 000000000..3e0c328a3 --- /dev/null +++ b/packages/shared-contracts/src/client-channel.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; +import { harnessModelRefSchema, harnessThinkingOptionIdSchema } from "./harness-models.js"; +import { harnessPermissionModeIdSchema } from "./harness-permission-modes.js"; + +/** Authenticated local owner channel. Notifications invalidate state; they are + * NOT appendable transcript deltas. Clients reconcile absolute snapshots. */ +export const CLIENT_CHANNEL_VERSION = 1; +export const CLIENT_CHANNEL_METHODS = [ + "thread/list", + "thread/read", + "thread/turns/list", + "thread/items/list", + "thread/start", + "thread/name/set", + "thread/archive", + "thread/unarchive", + "turn/start", + "turn/interrupt", + "turn/steer", + "codexhost/harness/plugins/list", + "codexhost/harness/inspect", + "codexhost/thread/inspect", + "codexhost/thread/ownership/list", + "codexhost/thread/model/select", + "codexhost/thread/thinking/select", + "codexhost/thread/permission-mode/select", + "codexhost/thread/commands/inspect", + "codexhost/thread/command/execute", + "codexhost/thread/fork", + "codexhost/harness/session-import/sources", + "codexhost/harness/session-import/list", + "codexhost/harness/session-import/import", +] as const; +const record = z.record(z.string(), z.unknown()); +export const clientChannelCursorSchema = z.object({ + epoch: z.string().uuid(), + sequence: z.number().int().nonnegative(), +}); +export type ClientChannelCursor = z.infer; +export const clientChannelEventSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("hello"), + version: z.literal(1), + cursor: clientChannelCursorSchema, + reset: z.boolean(), + }), + z.object({ + type: z.literal("changed"), + cursor: clientChannelCursorSchema, + threadId: z.string().min(1), + method: z.string().min(1), + }), +]); +export type ClientChannelEvent = z.infer; +const clientQuestionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("text"), + id: z.string(), + prompt: z.string(), + secret: z.boolean().optional(), + optional: z.boolean().optional(), + }), + z.object({ + type: z.literal("choice"), + id: z.string(), + prompt: z.string(), + multiple: z.boolean(), + allowOther: z.boolean(), + optional: z.boolean().optional(), + options: z.array( + z.object({ value: z.string(), label: z.string(), description: z.string().optional() }), + ), + }), +]); +const clientInteractionValueSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("approval"), + title: z.string(), + description: z.string().optional(), + subject: z.unknown().optional(), + actions: z.array(z.object({ id: z.string(), label: z.string(), effect: z.string() })), + }), + z.object({ + type: z.literal("question"), + title: z.string().optional(), + questions: z.array(clientQuestionSchema), + }), +]); +export const clientInteractionSchema = z.object({ + requestId: z.union([z.string(), z.number().int()]), + threadId: z.string(), + kind: z.enum(["approval", "question"]), + interaction: clientInteractionValueSchema, + request: record, +}); +export type ClientInteraction = z.infer; +const clientTurnSchema = z + .object({ + id: z.string().min(1), + status: z.string().min(1), + items: z.array(z.object({ id: z.string().min(1), type: z.string().min(1) }).passthrough()), + error: z.object({ message: z.string().optional() }).passthrough().nullable().optional(), + }) + .passthrough(); +export const clientThreadSnapshotSchema = z.object({ + cursor: clientChannelCursorSchema, + thread: z + .object({ + id: z.string().min(1), + cwd: z.string(), + modelProvider: z.literal("codexhost"), + createdAt: z.number(), + updatedAt: z.number(), + status: z.object({ type: z.string() }).passthrough(), + name: z.string().nullable().optional(), + preview: z.string().optional(), + turns: z.array(clientTurnSchema).length(0), + }) + .passthrough(), + turnsPage: z.object({ + data: z.array(clientTurnSchema).max(5), + nextCursor: z.string().nullable(), + }), + configuration: z.object({ + effectiveModel: harnessModelRefSchema.optional(), + effectiveThinkingOptionId: harnessThinkingOptionIdSchema.optional(), + effectivePermissionModeId: harnessPermissionModeIdSchema.optional(), + }), + interactions: z.array(clientInteractionSchema), +}); +export type ClientThreadSnapshot = z.infer; +export const clientChannelDescriptorSchema = z.object({ + version: z.literal(CLIENT_CHANNEL_VERSION), + pid: z.number().int().positive(), + port: z.number().int().min(1).max(65535), + token: z.string().regex(/^[0-9a-f]{64}$/u), + startedAt: z.number(), + epoch: z.string().uuid(), +}); +export type ClientChannelDescriptor = z.infer; +export const clientChannelResponseSchema = z.object({ + epoch: z.string().uuid(), + threadId: z.string().min(1), + requestId: z.union([z.string(), z.number().int()]), + result: record, +}); +export type ClientChannelResponse = z.infer; diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 8d1b389f9..78f386579 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +export * from "./client-channel.js"; export * from "./delegation-read.js"; export * from "./credential-imports.js"; export * from "./harness-display-settings.js"; diff --git a/packages/web-server/README.md b/packages/web-server/README.md index 3342580c3..1d2b4b629 100644 --- a/packages/web-server/README.md +++ b/packages/web-server/README.md @@ -4,8 +4,8 @@ A separate Node server for the responsive DSH-derived UI. By default it uses the Build from the repository root with `npm run build:web`. The default plugin input is the same checkout's `packages/host-runtime/dist/plugins`, built by the existing CH release plugin builder; the installed CodexHost.app is not consulted. Usage-only plugins are excluded from conversations. The standalone Codex Adapter lives in `packages/adapters/codex`; no Harness implementation is duplicated here. -`npm run start:web` uses the isolated, authenticated preview and connects to the existing local CH Host. The Desktop must already be running through codexhost; an offline Host never falls back to a new independent session. Add `-- --ch-cdp http://127.0.0.1:` if automatic Desktop endpoint discovery is unavailable. For the independent plugin mode, use `-- --session-source standalone`, with Claude Code enabled by default. Add `-- --harness all` to enable every bundled session plugin except Codex/Pi, or `-- --harness ` to select specific plugins. Codex/Pi, missing plugins and usage-only plugins are rejected before starting; selection does not install/authenticate CLIs or imply full capability verification. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. +`npm run start:web` uses the isolated, authenticated preview and connects to the existing local CH Host. The Desktop must already be running through codexhost; an offline Host never falls back to a new independent session. Updated local Hosts provide an authenticated event channel, discovered in `~/.codexhost/client-hosts`; use `-- --ch-control-directory /client-hosts` for a custom Host data directory. Older Hosts retain the legacy CDP polling path. Supplying only `-- --ch-cdp http://127.0.0.1:` explicitly selects that legacy path; combine it with the control directory to use events with explicit CDP project-metadata discovery. Building Web does not update the running Desktop Host. For the independent plugin mode, use `-- --session-source standalone`, with Claude Code enabled by default. Add `-- --harness all` to enable every bundled session plugin except Codex/Pi, or `-- --harness ` to select specific plugins. Codex/Pi, missing plugins and usage-only plugins are rejected before starting; selection does not install/authenticate CLIs or imply full capability verification. The packed entry is `dist/codexhost-web/preview.mjs`; it strips inherited Desktop routing and Node injection variables, preserves CLI credentials/PATH, and stores preview data separately. `server.mjs` is the configurable full entry and must not be launched with inherited Desktop routing state. -Node test-runner suites are owned by this package's `test`, `test:distribution` and `test:browser` scripts; the root TypeScript test command invokes the unit suites separately from Vitest. The browser and distribution suites require built frontend artifacts. They use temporary data, fake Harnesses and a fake CH/CDP peer, never real model submissions. See the feature guide for shared-mode limitations, including GUI-owned approvals and polling rather than full live notification relay. +Node test-runner suites are owned by this package's `test`, `test:distribution` and `test:browser` scripts; the root TypeScript test command invokes the unit suites separately from Vitest. The browser and distribution suites require built frontend artifacts. They use temporary data, fake Harnesses and a fake CH/CDP peer, never real model submissions. The generic authenticated App gateway is `/api/ch/v1/{events,rpc,snapshot,respond}`. It uses the same Host owner and never exposes its private discovery token. See [the client protocol](../../docs/architecture/host-client-channel.md) for invalidation/snapshot recovery, arbitration and bounded receipts. Event-mode Web shares approvals/questions; legacy CDP mode still handles them in GUI. Project metadata retains periodic checks; attachments, shared Web Steering and complete background/subagent projections remain limited. The initial Web implementation is imported from the MIT-licensed `codexhost-web` fork; its upstream license is retained in `LICENSE.upstream`. Current behavior, limitations and ownership are documented in [standalone-web.md](../../docs/product/standalone-web.md). diff --git a/packages/web-server/scripts/preview.ts b/packages/web-server/scripts/preview.ts index fd281856a..da526e5ea 100644 --- a/packages/web-server/scripts/preview.ts +++ b/packages/web-server/scripts/preview.ts @@ -10,6 +10,7 @@ const { values } = parseArgs({ options: { "session-source": { type: "string" }, "ch-cdp": { type: "string" }, + "ch-control-directory": { type: "string" }, port: { type: "string" }, data: { type: "string" }, workspace: { type: "string" }, diff --git a/packages/web-server/src/ch-client-gateway.ts b/packages/web-server/src/ch-client-gateway.ts new file mode 100644 index 000000000..9f1ebd1e1 --- /dev/null +++ b/packages/web-server/src/ch-client-gateway.ts @@ -0,0 +1,116 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + clientChannelCursorSchema, + clientChannelResponseSchema, +} from "@codexhost/shared-contracts"; +import type { ChHostClient } from "./ch-host-client.ts"; + +/** Generic App protocol behind the Web server's auth + same-origin gate. Never + * exposes the private loopback descriptor/token or creates an execution owner. */ +export async function serveChClientGateway( + host: ChHostClient | undefined, + request: IncomingMessage, + response: ServerResponse, +): Promise { + const reply = (status: number, value: unknown) => { + response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + response.end(JSON.stringify(value)); + }; + const channel = host?.realtime; + if (!channel) { + reply(503, { + error: { + code: -32090, + message: "This Host does not provide client channel v1; update CH to enable it", + }, + }); + return; + } + try { + const url = new URL(request.url ?? "/", "http://localhost"); + const route = url.pathname.slice("/api/ch".length); + if (request.method === "GET" && route === "/v1/events") { + const after = url.searchParams.has("epoch") + ? clientChannelCursorSchema.parse({ + epoch: url.searchParams.get("epoch"), + sequence: Number(url.searchParams.get("after")), + }) + : undefined; + const stop = channel.subscribe((event) => { + if (event.type === "connection") { + if (!event.online && !response.writableEnded && !response.destroyed) { + if (!response.headersSent) + reply(503, { error: { code: -32090, message: "CH is reconnecting" } }); + else response.end(); + } + return; + } + if (response.destroyed || response.writableEnded) return; + if (!response.headersSent) + response.writeHead(200, { + "content-type": "application/x-ndjson", + "cache-control": "no-store", + }); + if (response.writableLength > 256 * 1024) { + response.destroy(); + return; + } + response.write(JSON.stringify(event) + "\n"); + }, after); + const heartbeat = setInterval(() => { + if (response.headersSent && !response.writableEnded) response.write("\n"); + }, 15_000); + heartbeat.unref(); + response.on("close", () => { + clearInterval(heartbeat); + stop(); + }); + return; + } + if (request.method !== "POST") { + reply(404, { error: { code: -32601, message: "Not found" } }); + return; + } + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of request) { + const part = Buffer.from(chunk as Uint8Array); + size += part.length; + if (size > 1024 * 1024) throw new Error("Client request is too large"); + chunks.push(part); + } + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Expected a request object"); + const input = value as Record; + if (route === "/v1/snapshot" && typeof input.threadId === "string") + reply(200, { result: await channel.snapshot(input.threadId) }); + else if (route === "/v1/respond") + reply(200, { result: await channel.respond(clientChannelResponseSchema.parse(input)) }); + else if ( + route === "/v1/rpc" && + typeof input.method === "string" && + input.params && + typeof input.params === "object" && + !Array.isArray(input.params) + ) + reply(200, { + result: await channel.request(input.method, input.params as Record), + }); + else reply(404, { error: { code: -32601, message: "Not found" } }); + } catch (error) { + if (response.headersSent) { + response.destroy(); + return; + } + reply(400, { + error: { + code: + error instanceof Error && "code" in error && typeof error.code === "number" + ? error.code + : -32603, + message: error instanceof Error ? error.message : "Client operation failed", + }, + }); + } +} diff --git a/packages/web-server/src/ch-host-client.ts b/packages/web-server/src/ch-host-client.ts index 97c357e55..ae628295f 100644 --- a/packages/web-server/src/ch-host-client.ts +++ b/packages/web-server/src/ch-host-client.ts @@ -3,13 +3,32 @@ */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { CdpClient, listCdpTargets } from "@codexhost/desktop-control"; +import { + CdpClient, + listCdpTargets, + HostClientChannel, + discoverHostClientChannel, + type HostClientUpdate, +} from "@codexhost/desktop-control"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { + ClientThreadSnapshot, + ClientChannelResponse, + ClientChannelCursor, +} from "@codexhost/shared-contracts"; import { RpcError } from "./transport.ts"; export interface ChHostClient { request(method: string, params: Record): Promise; projects(threadIds: string[]): Promise; close(): void; + readonly realtime?: { + subscribe(listener: (event: HostClientUpdate) => void, after?: ClientChannelCursor): () => void; + request(method: string, params: Record): Promise; + snapshot(threadId: string): Promise; + respond(input: ClientChannelResponse): Promise<{ resolved: boolean }>; + }; } export interface ChProjectSnapshot { @@ -161,3 +180,46 @@ export class DesktopChHostClient implements ChHostClient { this.client = undefined; } } + +/** Prefer the stable owner channel; explicit CDP remains the legacy/test route. + * Once selected, an event connection never silently falls back after a failure. + */ +export async function createChHostClient( + options: { cdp?: string; directory?: string } = {}, +): Promise { + const desktop = new DesktopChHostClient(options.cdp); + const directory = options.directory ?? join(homedir(), ".codexhost", "client-hosts"); + const endpoint = + options.cdp && !options.directory ? null : await discoverHostClientChannel(directory); + if (!endpoint && options.directory !== undefined) + throw new RpcError( + "host/unavailable", + "CH client channel not found; start an updated local Host or check --ch-control-directory.", + ); + if (!endpoint) return desktop; + const channel = new HostClientChannel(directory, endpoint); + try { + await channel.start(); + } catch (error) { + channel.close(); + throw error; + } + return { + realtime: channel, + async request(method: string, params: Record): Promise { + try { + return await channel.request(method, params); + } catch (error) { + throw new RpcError( + "host/rejected", + error instanceof Error ? error.message : "Host request failed", + ); + } + }, + projects: (ids) => desktop.projects(ids), + close() { + channel.close(); + desktop.close(); + }, + }; +} diff --git a/packages/web-server/src/ch-interactions.ts b/packages/web-server/src/ch-interactions.ts new file mode 100644 index 000000000..95c5e4e1f --- /dev/null +++ b/packages/web-server/src/ch-interactions.ts @@ -0,0 +1,153 @@ +import type { ClientInteraction, ClientThreadSnapshot } from "@codexhost/shared-contracts"; +import type { ChHostClient } from "./ch-host-client.ts"; +import type { EventHub } from "./transport.ts"; + +type Answer = { answers?: Array<{ id: string; selected?: string[]; custom?: string }> }; +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} +/** Translate the public Host's existing Codex UI response shape, not Harness SDK + * protocols. The Host validates native options again and arbitrates with GUI. */ +function presentation(pending: ClientInteraction): { + questions: unknown[]; + result: (answer: unknown) => Record; +} { + const interaction = pending.interaction; + if (interaction.type === "approval") { + const labels = interaction.actions.map((action, index) => + interaction.actions.filter((other) => other.label === action.label).length > 1 + ? `${action.label} (${index + 1})` + : action.label, + ); + return { + questions: [ + { + id: "approval", + header: interaction.title, + question: [ + interaction.title, + interaction.description, + interaction.subject ? JSON.stringify(interaction.subject, null, 2) : undefined, + ] + .filter(Boolean) + .join("\n\n"), + options: labels.map((label) => ({ label })), + multiSelect: false, + }, + ], + result(value) { + const answer = (value as Answer | undefined)?.answers?.find( + (entry) => entry.id === "approval", + ); + if (value == null || (answer?.selected?.length === 0 && !answer.custom)) + return { action: "decline" }; + const action = interaction.actions[labels.indexOf(answer?.selected?.[0] ?? "")]; + if (!action || answer?.custom || answer?.selected?.length !== 1) + throw new Error("Choose one of the native approval actions"); + const params = record(pending.request.params); + if ("actionId" in record(record(params.requestedSchema).properties)) + return { action: "accept", content: { actionId: action.id } }; + if (action.effect === "deny") return { action: "decline" }; + if (action.effect === "allowOnce") return { action: "accept", content: {} }; + if (action.effect === "allowForSession" || action.effect === "allowAlways") + return { + action: "accept", + content: {}, + _meta: { persist: action.effect === "allowAlways" ? "always" : "session" }, + }; + throw new Error("Host approval action is unsupported by this client version"); + }, + }; + } + return { + questions: interaction.questions.map((question) => ({ + id: question.id, + question: question.prompt, + ...(interaction.title ? { header: interaction.title } : {}), + ...(question.type === "choice" + ? { + options: question.options.map((option) => ({ + label: option.label, + description: option.description ?? "", + })), + multiSelect: question.multiple, + } + : {}), + })), + result(value) { + const answers: Record = {}; + for (const answer of (value as Answer | undefined)?.answers ?? []) { + const question = interaction.questions.find((question) => question.id === answer.id); + if (!question) throw new Error("Unknown native question"); + if (question.type === "choice" && answer.custom && !question.allowOther) + throw new Error("This native question only accepts its listed choices"); + answers[answer.id] = { + answers: [...(answer.selected ?? []), ...(answer.custom ? [answer.custom] : [])], + }; + } + return { answers }; + }, + }; +} + +export class ChInteractions { + private pending = new Map void }>(); + constructor( + private readonly host: NonNullable, + private readonly events: EventHub, + private readonly error: (id: string, message: string | null) => void, + ) {} + update(snapshot: ClientThreadSnapshot): void { + const threadId = String(snapshot.thread.id); + if (!snapshot.interactions.length) this.error(threadId, null); + const keys = new Set( + snapshot.interactions.map((value) => `${snapshot.cursor.epoch}:${value.requestId}`), + ); + for (const [key, pending] of this.pending) + if (pending.threadId === threadId && !keys.has(key)) { + this.pending.delete(key); + pending.cancel(); + } + for (const request of snapshot.interactions) { + const key = `${snapshot.cursor.epoch}:${request.requestId}`; + if (this.pending.has(key)) continue; + const view = presentation(request); + const invocation = this.events.invoke("user-questions/request", threadId, { + questions: view.questions, + }); + const pending = { threadId, cancel: invocation.cancel }; + this.pending.set(key, pending); + void invocation.result.then( + async (answer) => { + if (this.pending.get(key) !== pending) return; + try { + await this.host.respond({ + epoch: snapshot.cursor.epoch, + requestId: request.requestId, + threadId, + result: view.result(answer), + }); + this.error(threadId, null); + } catch (error) { + this.error( + threadId, + error instanceof Error ? error.message : "Interaction response failed", + ); + // Read-only reconciliation, never resend an answer after an unknown outcome. + this.pending.delete(key); + const current = await this.host.snapshot(threadId).catch(() => undefined); + if (current) this.update(current); + } + }, + () => {}, + ); + } + } + clear(): void { + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const value of pending) value.cancel(); + } +} diff --git a/packages/web-server/src/ch-realtime.ts b/packages/web-server/src/ch-realtime.ts new file mode 100644 index 000000000..4af0f597d --- /dev/null +++ b/packages/web-server/src/ch-realtime.ts @@ -0,0 +1,93 @@ +import type { HostClientUpdate } from "@codexhost/desktop-control"; +import type { ChHostClient } from "./ch-host-client.ts"; + +/** Coalesce invalidations, not tokens. Only followed Threads read bounded absolute + * state; a change arriving during a read schedules another reconciliation. */ +export class ChRealtime { + private watched = new Map(); + private dirty = new Set(); + private running = new Set(); + private timers = new Map>(); + private closed = false; + private online = false; + private readonly unsubscribe: () => void; + constructor( + host: NonNullable, + private readonly callbacks: { + thread: (id: string) => Promise; + catalog: () => Promise; + connection: (online: boolean) => void; + reset: (epoch: string) => void; + error: (id: string, error: unknown) => void; + }, + ) { + this.unsubscribe = host.subscribe((event) => this.receive(event)); + } + invalidate(id: string): void { + if (this.watched.has(id)) this.schedule(id); + } + follow(id: string): () => void { + this.watched.set(id, (this.watched.get(id) ?? 0) + 1); + return () => { + const count = (this.watched.get(id) ?? 1) - 1; + if (count) this.watched.set(id, count); + else this.watched.delete(id); + }; + } + private receive(event: HostClientUpdate): void { + if (this.closed) return; + if (event.type === "connection") { + this.online = event.online; + this.callbacks.connection(event.online); + if (!event.online) { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + } + return; + } + if (event.type === "hello") { + this.callbacks.reset(event.cursor.epoch); + for (const id of this.watched.keys()) this.schedule(id); + this.schedule(""); + } else { + if (this.watched.has(event.threadId)) this.schedule(event.threadId); + if (event.method.startsWith("thread/") || event.method === "turn/completed") + this.schedule(""); + } + } + private schedule(id: string, delay = 100): void { + this.dirty.add(id); + if (!this.online || this.closed || this.running.has(id) || this.timers.has(id)) return; + const timer = setTimeout(() => { + this.timers.delete(id); + void this.reconcile(id); + }, delay); + timer.unref(); + this.timers.set(id, timer); + } + private async reconcile(id: string): Promise { + this.dirty.delete(id); + if (this.closed || !this.online || (id && !this.watched.has(id))) return; + this.running.add(id); + let delay = 100; + try { + if (id) await this.callbacks.thread(id); + else await this.callbacks.catalog(); + } catch (error) { + this.callbacks.error(id, error); + this.dirty.add(id); + delay = 1000; + } finally { + this.running.delete(id); + if (this.dirty.has(id)) this.schedule(id, delay); + } + } + close(): void { + this.closed = true; + this.unsubscribe(); + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + this.watched.clear(); + this.dirty.clear(); + } +} diff --git a/packages/web-server/src/ch-sessions.ts b/packages/web-server/src/ch-sessions.ts index cebd56fac..3262f925d 100644 --- a/packages/web-server/src/ch-sessions.ts +++ b/packages/web-server/src/ch-sessions.ts @@ -13,6 +13,8 @@ import type { Inspection } from "./harnesses.ts"; import { nativePermissionView } from "./session-presentation.ts"; import { projectGroupRoot } from "./ch-project-groups.ts"; import { ChThreadHistory } from "./ch-thread-history.ts"; +import { ChRealtime } from "./ch-realtime.ts"; +import { ChInteractions } from "./ch-interactions.ts"; import { requestOf } from "./store.ts"; import { type EventHub, @@ -48,6 +50,11 @@ export class ChSessions { private refreshing: Promise | undefined; private timer?: NodeJS.Timeout; private closed = false; + private readonly realtime: ChRealtime | undefined; + private readonly interactions: ChInteractions | undefined; + private hostOnline = true; + private hostEpoch: string | undefined; + private readonly viewOrigins = new Map(); notifier: ((notification: SessionNotification) => void) | undefined; constructor( private readonly host: ChHostClient, @@ -55,6 +62,48 @@ export class ChSessions { private readonly events: EventHub, ) { this.catalog = new ChHarnessCatalog(host); + this.interactions = host.realtime + ? new ChInteractions(host.realtime, events, (id, message) => { + this.views.get(id)?.log.setProjection("nativeInteractionError", message); + }) + : undefined; + this.realtime = host.realtime + ? new ChRealtime(host.realtime, { + thread: (id) => this.view(id), + catalog: () => this.refresh(), + connection: (online) => { + this.hostOnline = online; + if (!online) this.interactions?.clear(); + for (const view of this.views.values()) + view.log.setProjection("nativeConnection", { + state: online ? "connected" : "reconnecting", + }); + }, + reset: (epoch) => { + if (this.hostEpoch && this.hostEpoch !== epoch) { + const old = [...this.views.values()]; + this.views.clear(); + this.readingViews.clear(); + this.loadingControls.clear(); + this.historyReadAt.clear(); + this.interactions?.clear(); + for (const view of old) { + view.retire(); + this.viewOrigins.set(view.thread.id, view.log.lastSeq + 2); + view.log.reconnectFollowers(); + } + } + this.hostEpoch = epoch; + }, + error: (id, error) => { + if (error instanceof RpcError && error.code === "host/resync") return; + this.views.get(id)?.log.setProjection("nativeConnection", { + state: "reconnecting", + message: error instanceof Error ? error.message : "CH state is unavailable", + }); + }, + }) + : undefined; } private id(id: string): string { @@ -166,6 +215,7 @@ export class ChSessions { const row = this.rows.get(id); if ( reuseIdle && + !this.realtime && cached && row && row.status.type !== "active" && @@ -174,7 +224,22 @@ export class ChSessions { Date.now() - (this.historyReadAt.get(id) ?? 0) < 30_000 ) return Promise.resolve(cached); - const reading = this.readView(id, !reuseIdle).finally(() => this.readingViews.delete(id)); + const reading = this.readView(id, !reuseIdle) + .catch((error: unknown) => { + if ( + !this.closed && + error instanceof Error && + "code" in error && + (error.code === "host/resync" || error.code === -32094) + ) { + if (this.readingViews.get(id) === reading) this.readingViews.delete(id); + return this.view(id, reuseIdle); + } + throw error; + }) + .finally(() => { + if (this.readingViews.get(id) === reading) this.readingViews.delete(id); + }); this.readingViews.set(id, reading); return reading; } @@ -187,18 +252,43 @@ export class ChSessions { throw new RpcError("session/not-external", "CH did not confirm external Thread ownership."); let view = this.views.get(id); if (!view) { - view = new ChThreadHistory(this.host, row, harnessId, (sessionId, key, value, seq) => { - for (const sink of this.controls) - sink.push({ type: "projection", sessionId, key, value, seq }); - }); + view = new ChThreadHistory( + this.host, + row, + harnessId, + (sessionId, key, value, seq) => { + for (const sink of this.controls) + sink.push({ type: "projection", sessionId, key, value, seq }); + }, + this.viewOrigins.get(id), + ); this.views.set(id, view); } - await view.refresh(force); + try { + await view.refresh(force); + } catch (error) { + if (error instanceof RpcError && error.code === "host/history-changed") { + view.retire(); + this.views.delete(id); + this.historyReadAt.delete(id); + this.loadingControls.delete(id); + view.log.invalidate(error.code, error.message); + } + throw error; + } + if (this.views.get(id) !== view) + throw new RpcError("host/resync", "Host snapshot was superseded by a new generation"); + if (view.ownerSnapshot && this.hostOnline && !this.closed) + this.interactions?.update(view.ownerSnapshot); + if (this.realtime) + view.log.setProjection("nativeConnection", { + state: this.hostOnline ? "connected" : "reconnecting", + }); this.historyReadAt.set(id, Date.now()); - view.log.setProjection("harnessIdentity", { - id: harnessId, - name: await this.catalog.name(harnessId), - }); + const name = await this.catalog.name(harnessId); + if (this.views.get(id) !== view) + throw new RpcError("host/resync", "Host snapshot was superseded by a new generation"); + view.log.setProjection("harnessIdentity", { id: harnessId, name }); this.loadControls(view, harnessId, force); return view; } @@ -206,9 +296,13 @@ export class ChSessions { const id = view.thread.id; if (!force && this.loadingControls.has(id)) return; // Runtime inspection/config discovery must not gate the history snapshot. - const loading = Promise.all([this.owner(id), this.catalog.inspect(harnessId, view.thread.cwd)]) + const loading = Promise.all([ + view.ownerSnapshot ? Promise.resolve(view.ownerSnapshot.configuration) : this.owner(id), + this.catalog.inspect(harnessId, view.thread.cwd), + ]) .then(([owner, inspection]) => { - if (this.closed || this.loadingControls.get(id) !== loading) return; + if (this.closed || this.views.get(id) !== view || this.loadingControls.get(id) !== loading) + return; view.log.setProjection( "nativePermissions", nativePermissionView(harnessId, inspection as Inspection, true), @@ -409,8 +503,19 @@ export class ChSessions { input: request.content, ...(request.requestId ? { clientUserMessageId: request.requestId } : {}), }); - this.events.emit("api-session/status", threadId, true); - await this.view(threadId); + try { + const view = await this.view(threadId); + this.events.emit("api-session/status", threadId, view.thread.status.type === "active"); + } catch { + // A failed read cannot turn an acknowledged write into a rejected send. + if (this.realtime) { + this.views.get(threadId)?.log.setProjection("nativeConnection", { + state: "reconnecting", + message: "CH accepted your message; waiting to synchronize its state.", + }); + this.realtime.invalidate(threadId); + } + } return { accepted: true }; }); rpc.register("session/cancel", async (args) => { @@ -518,6 +623,8 @@ export class ChSessions { }); return; } + const unfollow = this.realtime?.follow(this.id(request.address.sessionId)); + sink.onClose(() => unfollow?.()); const view = await this.view(request.address.sessionId, true); view.log.follow(sink, request); // Publish first; prefetch just one older page, never drain all history. @@ -525,6 +632,7 @@ export class ChSessions { if (!sink.closed && !this.closed) void view.prefetchOlder().catch(() => {}); }); sink.onClose(() => clearImmediate(prefetch)); + if (this.realtime) return; let busy = false; const poll = setInterval(() => { if (busy || sink.closed) return; @@ -621,6 +729,8 @@ export class ChSessions { } async close(): Promise { this.closed = true; + this.realtime?.close(); + this.interactions?.clear(); clearInterval(this.timer); this.host.close(); for (const sink of this.controls) sink.end(); diff --git a/packages/web-server/src/ch-thread-history.ts b/packages/web-server/src/ch-thread-history.ts index 58385cbb8..0109712fb 100644 --- a/packages/web-server/src/ch-thread-history.ts +++ b/packages/web-server/src/ch-thread-history.ts @@ -1,5 +1,6 @@ /** Native CH pagination feeding a disposable, backwards-extensible Web journal. */ import type { ChHostClient } from "./ch-host-client.ts"; +import type { ClientThreadSnapshot } from "@codexhost/shared-contracts"; import { ChThreadView, type ChThread, type ChTurn } from "./ch-thread-view.ts"; import type { ProjectionListener } from "./session-log.ts"; import { RpcError } from "./transport.ts"; @@ -17,6 +18,7 @@ interface TurnsPage { export class ChThreadHistory extends ChThreadView { private loaded = false; + ownerSnapshot: ClientThreadSnapshot | undefined; private lastHeadReadAt = 0; private prefetched = false; private refreshing: Promise | undefined; @@ -29,14 +31,15 @@ export class ChThreadHistory extends ChThreadView { thread: ChThread, harnessId: string, onProjection: ProjectionListener, + origin = RENDER_ORIGIN, ) { const pagination = { cursor: null as string | null }; super( { ...thread, turns: [] }, harnessId, onProjection, - RENDER_ORIGIN, - RENDER_ORIGIN, + origin, + origin, () => pagination.cursor !== null, ); this.pagination = pagination; @@ -65,10 +68,15 @@ export class ChThreadHistory extends ChThreadView { */ refresh(force = false): Promise { this.refreshing ??= (async () => { - const { thread } = await this.host.request<{ thread: ChThread }>("thread/read", { - threadId: this.thread.id, - includeTurns: false, - }); + const snapshot = await this.host.realtime?.snapshot(this.thread.id); + const { thread } = snapshot + ? { thread: snapshot.thread } + : await this.host.request<{ thread: ChThread }>("thread/read", { + threadId: this.thread.id, + includeTurns: false, + }); + this.ensureCurrent(); + this.ownerSnapshot = snapshot; if ( !force && this.loaded && @@ -81,7 +89,7 @@ export class ChThreadHistory extends ChThreadView { this.log.setProjection("title", thread.name ?? thread.preview ?? null); return; } - const recent = await this.page(null); + const recent = snapshot ? snapshot.turnsPage : await this.page(null); const descending = [...recent.data]; const newestKnown = this.thread.turns.at(-1)?.id; let cursor = recent.nextCursor; diff --git a/packages/web-server/src/ch-thread-view.ts b/packages/web-server/src/ch-thread-view.ts index 53be3bcc3..faaa51df1 100644 --- a/packages/web-server/src/ch-thread-view.ts +++ b/packages/web-server/src/ch-thread-view.ts @@ -1,6 +1,12 @@ /** In-memory projection of the CH protocol onto the existing Web UI wire format. */ import { SessionLog, type ProjectionListener } from "./session-log.ts"; -import { TurnProjector, type HostItem, type TurnOutcomeShape } from "./projector.ts"; +import { RpcError } from "./transport.ts"; +import { + TurnProjector, + type HostItem, + type HostItemOutcome, + type TurnOutcomeShape, +} from "./projector.ts"; export interface ChItem { id: string; @@ -11,14 +17,14 @@ export interface ChTurn { id: string; status: string; items: ChItem[]; - error?: { message?: string }; + error?: { message?: string | undefined } | null | undefined; } export interface ChThread { id: string; cwd: string; modelProvider: string; - name?: string | null; - preview?: string; + name?: string | null | undefined; + preview?: string | undefined; createdAt: number; updatedAt: number; status: { type: string }; @@ -100,8 +106,22 @@ export class ChThreadView { readonly log: SessionLog; private turns = new Map< string, - { projector: TurnProjector; items: Map; done: boolean } + { + projector: TurnProjector; + items: Map; + completed: Set; + done: boolean; + } >(); + private readonly liveOutput = new Map(); + private retired = false; + retire(): void { + this.retired = true; + } + protected ensureCurrent(): void { + if (this.retired) + throw new RpcError("host/resync", "Host snapshot was superseded by a new generation"); + } constructor( readonly thread: ChThread, private readonly harnessId: string, @@ -110,6 +130,7 @@ export class ChThreadView { sequenceStart = 0, hasEarlier: () => boolean = () => false, ) { + const published = new Map(); this.log = new SessionLog( { version: 4, @@ -126,7 +147,12 @@ export class ChThreadView { writeJson: () => {}, appendLine: () => {}, }, - onProjection, + (id, key, value, seq) => { + const fingerprint = JSON.stringify(value) ?? "undefined"; + if (published.get(key) === fingerprint) return; + published.set(key, fingerprint); + onProjection(id, key, value, seq); + }, sequenceStart, hasEarlier, ); @@ -134,6 +160,7 @@ export class ChThreadView { /** Older native pages are projected separately, without changing the visible tail. */ prepend(turns: ChTurn[]): void { + this.ensureCurrent(); if (!turns.length) return; if (turns.some((turn) => this.turns.has(turn.id) || !finished(turn.status))) throw new Error("Older CH history overlaps the loaded window or contains an active Turn"); @@ -148,6 +175,7 @@ export class ChThreadView { } update(thread: ChThread): void { + this.ensureCurrent(); Object.assign(this.thread, thread); this.log.setProjection("title", thread.name ?? thread.preview ?? null); this.log.setProjection("sessionListMetadata", { @@ -162,21 +190,30 @@ export class ChThreadView { turn: this.turnStart + this.turns.size + 1, cwd: thread.cwd, model: { provider: this.harnessId, model: "harness" }, + onToolOutput: (id, output) => { + if (output === undefined) this.liveOutput.delete(id); + else this.liveOutput.set(id, output.slice(-8000)); + }, }); const input = turn.items .filter((item) => item.type === "userMessage") .flatMap((item) => (Array.isArray(item.content) ? item.content : [])); - projector.begin(input, undefined); - view = { projector, items: new Map(), done: false }; + const clientId = turn.items.find( + (item) => item.type === "userMessage" && typeof item.clientId === "string", + )?.clientId; + projector.begin(input, typeof clientId === "string" ? clientId : undefined); + view = { projector, items: new Map(), completed: new Set(), done: false }; this.turns.set(turn.id, view); } if (view.done) continue; for (const item of turn.items) { const projected = itemOf(item); - if (!projected) continue; + if (!projected || view.completed.has(item.id)) continue; const old = view.items.get(item.id); if (!old) { view.projector.itemStarted(projected); + if (projected.type === "commandExecution" && projected.output) + view.projector.itemUpdated(item.id, { type: "output.append", text: projected.output }); } else if ( (projected.type === "agentMessage" || projected.type === "reasoning") && (old.type === "agentMessage" || old.type === "reasoning") && @@ -184,18 +221,50 @@ export class ChThreadView { ) { const text = projected.text.slice(old.text.length); if (text) view.projector.itemUpdated(item.id, { type: "text.append", text }); + } else if ( + (projected.type === "agentMessage" || projected.type === "reasoning") && + (old.type === "agentMessage" || old.type === "reasoning") + ) { + throw new RpcError( + "host/history-changed", + "CH changed already displayed text. Reload this page to reopen its current history.", + ); } else if (projected.type === "commandExecution" && old.type === "commandExecution") { - const text = (projected.output ?? "").slice((old.output ?? "").length); - if (text) view.projector.itemUpdated(item.id, { type: "output.append", text }); + const output = projected.output ?? ""; + if (output.startsWith(old.output ?? "")) { + const text = output.slice((old.output ?? "").length); + if (text) view.projector.itemUpdated(item.id, { type: "output.append", text }); + } else + view.projector.itemUpdated(item.id, { + type: "output.replace", + output: { content: [{ type: "text", text: output }] }, + }); } view.items.set(item.id, projected); + const itemOutcome: HostItemOutcome | undefined = + item.status === "completed" + ? { status: "succeeded" } + : item.status === "failed" + ? { + status: "failed", + error: { code: "nativeFailed", message: "Native tool execution failed" }, + } + : item.status === "declined" || item.status === "interrupted" + ? { status: "cancelled" } + : undefined; + if (itemOutcome) { + view.projector.itemCompleted(projected, itemOutcome); + view.completed.add(item.id); + } } if (finished(turn.status)) { for (const item of view.items.values()) - view.projector.itemCompleted(item, { status: "succeeded" }); + if (!view.completed.has(item.itemId)) + view.projector.itemCompleted(item, { status: "succeeded" }); view.projector.finish(outcome(turn)); view.done = true; } } + this.log.setProjection("codexhostToolOutput", Object.fromEntries(this.liveOutput)); } } diff --git a/packages/web-server/src/harness-icon-styles.ts b/packages/web-server/src/harness-icon-styles.ts new file mode 100644 index 000000000..b13fc4702 --- /dev/null +++ b/packages/web-server/src/harness-icon-styles.ts @@ -0,0 +1,18 @@ +import { + harnessPluginDescriptorSchema, + type HarnessPluginDescriptor, +} from "@codexhost/shared-contracts"; + +/** The same bounded presentation contract used by Desktop; no SVG markup or + * executable plugin code crosses into the browser. Original image bytes stay + * on the existing /harness-icons/:id endpoint. */ +export function harnessIconStyles( + plugins: Iterable<{ id: string; iconStyle?: unknown }>, +): Record { + return Object.fromEntries( + [...plugins].map((plugin) => { + const parsed = harnessPluginDescriptorSchema.shape.iconStyle.safeParse(plugin.iconStyle); + return [plugin.id, parsed.success ? (parsed.data ?? null) : null]; + }), + ); +} diff --git a/packages/web-server/src/harnesses.ts b/packages/web-server/src/harnesses.ts index e8a9eca04..d319a2590 100644 --- a/packages/web-server/src/harnesses.ts +++ b/packages/web-server/src/harnesses.ts @@ -15,7 +15,7 @@ import type { HarnessCommandCatalog, HarnessPluginManifest } from "@codexhost/sh export type HarnessManifest = Pick< HarnessPluginManifest, - "id" | "name" | "entry" | "icon" | "kind" + "id" | "name" | "entry" | "icon" | "iconStyle" | "kind" >; interface ModelRef { diff --git a/packages/web-server/src/main.ts b/packages/web-server/src/main.ts index 8b1e8c43a..ad6c6db4c 100644 --- a/packages/web-server/src/main.ts +++ b/packages/web-server/src/main.ts @@ -22,7 +22,9 @@ import { PushNotifier } from "./push.ts"; import { CLIENT_GLOBALS, CLIENT_PLUGINS } from "./plugins.ts"; import { Sessions } from "./sessions.ts"; import { ChSessions } from "./ch-sessions.ts"; -import { DesktopChHostClient } from "./ch-host-client.ts"; +import { createChHostClient } from "./ch-host-client.ts"; +import { serveChClientGateway } from "./ch-client-gateway.ts"; +import { harnessIconStyles } from "./harness-icon-styles.ts"; import { Settings } from "./settings.ts"; import { DataDir } from "./store.ts"; import { EventHub, RpcRegistry, StreamRegistry } from "./transport.ts"; @@ -43,6 +45,7 @@ const { values } = parseArgs({ options: { "session-source": { type: "string", default: "codexhost" }, "ch-cdp": { type: "string" }, + "ch-control-directory": { type: "string" }, port: { type: "string", default: process.env.CODEXHOST_WEB_PORT ?? "3180" }, host: { type: "string", default: process.env.CODEXHOST_WEB_HOST ?? "127.0.0.1" }, data: { @@ -85,10 +88,16 @@ const workspaces = new Workspaces( ); if (!["codexhost", "standalone"].includes(values["session-source"] as string)) throw new Error("Unknown --session-source"); -const sessions = - values["session-source"] === "standalone" - ? new Sessions(data, harnesses, workspaces, events) - : new ChSessions(new DesktopChHostClient(values["ch-cdp"]), workspaces, events); +const chHost = + values["session-source"] === "codexhost" + ? await createChHostClient({ + ...(values["ch-cdp"] ? { cdp: values["ch-cdp"] } : {}), + ...(values["ch-control-directory"] ? { directory: values["ch-control-directory"] } : {}), + }) + : undefined; +const sessions = chHost + ? new ChSessions(chHost, workspaces, events) + : new Sessions(data, harnesses, workspaces, events); const settings = new Settings(data, events); const push = new PushNotifier(data); sessions.notifier = (notification) => { @@ -187,6 +196,16 @@ const server = createServer((request, response) => { } } } + if (pathname.startsWith("/api/ch/v1/")) { + if (!sameOrigin(request)) { + response + .writeHead(403, { "content-type": "text/plain" }) + .end("cross-origin request rejected"); + return; + } + await serveChClientGateway(chHost, request, response); + return; + } if (request.method === "POST" && pathname.startsWith("/api/")) { if (!sameOrigin(request)) { response @@ -212,6 +231,20 @@ const server = createServer((request, response) => { send(response, assets.plugin(url), url.includes("rev=")); return; } + if (pathname === "/harness-icons/presentation.json") { + const plugins = + sessions instanceof ChSessions + ? await sessions.catalog.list() + : harnesses.ids().flatMap((id) => { + const manifest = harnesses.manifest(id); + return manifest ? [manifest] : []; + }); + send(response, { + contentType: "application/json", + body: Buffer.from(JSON.stringify(harnessIconStyles(plugins))), + }); + return; + } if (pathname.startsWith("/harness-icons/")) { const id = decodeURIComponent(pathname.slice("/harness-icons/".length)); if (sessions instanceof ChSessions) { diff --git a/packages/web-server/src/preview-launch.ts b/packages/web-server/src/preview-launch.ts index f4a14a93d..8eac2a42c 100644 --- a/packages/web-server/src/preview-launch.ts +++ b/packages/web-server/src/preview-launch.ts @@ -13,6 +13,7 @@ export interface PreviewOptions { harness?: string; "session-source"?: string; "ch-cdp"?: string; + "ch-control-directory"?: string; } /** @@ -51,6 +52,9 @@ export function previewLaunch( "--session-source", options["session-source"] ?? "codexhost", ...(options["ch-cdp"] ? ["--ch-cdp", options["ch-cdp"]] : []), + ...(options["ch-control-directory"] + ? ["--ch-control-directory", options["ch-control-directory"]] + : []), "--host", "127.0.0.1", "--port", diff --git a/packages/web-server/src/session-log.ts b/packages/web-server/src/session-log.ts index 53fdb1847..2f9fd263b 100644 --- a/packages/web-server/src/session-log.ts +++ b/packages/web-server/src/session-log.ts @@ -287,6 +287,16 @@ export class SessionLog { for (const sink of this.followers) sink.push({ type: "assistant-stream", frame }); } + /** End the carrier so clients reopen a fresh generation, retaining visible content until then. */ + reconnectFollowers(): void { + for (const sink of [...this.followers]) sink.end(); + } + + /** Retire a disposable history generation; existing clients must reopen it. */ + invalidate(code: string, message: string): void { + for (const sink of [...this.followers]) sink.fail(code, message); + } + // ---- followers --------------------------------------------------------------------------- follow(sink: StreamSink, request: PageWindow & { assistantStream?: boolean }): void { diff --git a/packages/web-server/test/browser/ch-realtime.test.ts b/packages/web-server/test/browser/ch-realtime.test.ts new file mode 100644 index 000000000..337f7c1db --- /dev/null +++ b/packages/web-server/test/browser/ch-realtime.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { it } from "node:test"; +import { chromium } from "playwright"; +import { FakeChChannel, startFakeChChannel } from "../support/ch-channel.ts"; +import { startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; +import { defined } from "../support/defined.ts"; + +it( + "two browsers share event-driven messages, approvals, configuration and Host restart recovery", + { timeout: 60_000 }, + async (t) => { + const directory = mkdtempSync(join(tmpdir(), "ch-browser-events-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const host = new FakeChChannel(), + row = host.add("gui-live", "/computer/shared", "Shared GUI Thread"); + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + let channel = await startFakeChChannel(host, directory); + t.after(() => channel.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + [ + "--session-source", + "codexhost", + "--ch-cdp", + debuggerServer.endpoint, + "--ch-control-directory", + directory, + ], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const a = await browser.newPage({ viewport: { width: 1280, height: 900 } }), + b = await browser.newPage({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + const errors: string[] = []; + for (const page of [a, b]) page.on("pageerror", (error) => errors.push(error.message)); + try { + for (const page of [a, b]) { + await page.goto(url); + await page.waitForLoadState("networkidle"); + const sidebar = page.getByRole("button", { name: "Open sidebar", exact: true }); + if (await sidebar.first().isVisible()) await sidebar.first().click(); + const folder = page.getByRole("treeitem").filter({ hasText: /^shared$/u }); + await folder.waitFor(); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + await page.getByRole("treeitem").filter({ hasText: "Shared GUI Thread" }).click(); + await page.getByText("existing CH answer", { exact: true }).waitFor(); + await page.locator('[data-native-connection="connected"]').waitFor(); + } + row.turns.push({ + id: "gui-turn", + status: "inProgress", + items: [ + { + id: "gui-user", + type: "userMessage", + content: [{ type: "text", text: "Message accepted in GUI" }], + }, + { id: "gui-agent", type: "agentMessage", text: "GUI stream: one" }, + ], + }); + row.status = { type: "active" }; + host.changed(row.id, "turn/started"); + for (const page of [a, b]) + await page.getByText("GUI stream: one", { exact: true }).waitFor({ timeout: 3000 }); + const live = defined(row.turns.at(-1)); + defined(live.items[1]).text = "GUI stream: one two"; + host.changed(row.id); + for (const page of [a, b]) { + await page.getByText("GUI stream: one two", { exact: true }).waitFor({ timeout: 3000 }); + assert.equal(await page.getByText("GUI stream: one two", { exact: true }).count(), 1); + } + live.status = "completed"; + row.status = { type: "idle" }; + host.changed(row.id, "turn/completed"); + for (const [page, text] of [ + [a, "message from A"], + [b, "message from B"], + ] as const) { + await page + .getByRole("textbox", { + name: "Message or run a task, / commands, @ files or sessions", + exact: true, + }) + .fill(text); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + for (const viewer of [a, b]) + await viewer + .getByText(`CH response: ${text}`, { exact: true }) + .waitFor({ timeout: 3000 }); + } + host.modes.set(row.id, "auto"); + host.changed(row.id, "codexhost/thread/configuration/updated"); + for (const page of [a, b]) + await page + .getByRole("button", { name: "Access mode, current: Native Auto", exact: true }) + .waitFor({ timeout: 3000 }); + host.pending = [ + { + requestId: -17, + threadId: row.id, + kind: "approval", + interaction: { + type: "approval", + title: "Approve native test action", + actions: [ + { id: "once", label: "Allow once", effect: "allowOnce" }, + { id: "no", label: "Deny", effect: "deny" }, + ], + }, + request: { + method: "mcpServer/elicitation/request", + params: { requestedSchema: { type: "object", properties: {} } }, + }, + }, + ]; + host.changed(row.id, "mcpServer/elicitation/request"); + for (const page of [a, b]) + await page + .getByRole("radio", { name: "Allow once", exact: true }) + .waitFor({ timeout: 3000 }); + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await b.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, "realtime-mobile-approval.png"), + }); + } + await a.getByRole("radio", { name: "Allow once", exact: true }).click(); + await a.getByRole("radio", { name: "Allow once", exact: true }).press("Enter"); + for (const page of [a, b]) + await page + .getByRole("radio", { name: "Allow once", exact: true }) + .waitFor({ state: "hidden", timeout: 3000 }); + assert.equal(host.answers.length, 1); + assert.deepEqual(host.answers[0]?.result, { action: "accept", content: {} }); + await channel.close(); + for (const page of [a, b]) { + await page.locator('[data-native-connection="reconnecting"]').waitFor({ timeout: 3000 }); + await page.getByText("CH response: message from B", { exact: true }).waitFor(); + } + host.epoch = randomUUID(); + host.sequence = 0; + // The restored native history can have different Turn/Item IDs. A new Host + // epoch replaces the Web generation rather than appending duplicate text. + for (const turn of row.turns) { + turn.id = `restored-${turn.id}`; + for (const item of turn.items) item.id = `restored-${item.id}`; + } + row.turns.push({ + id: "offline-turn", + status: "completed", + items: [ + { + id: "offline-user", + type: "userMessage", + content: [{ type: "text", text: "While disconnected" }], + }, + { id: "offline-agent", type: "agentMessage", text: "Recovered authoritative answer" }, + ], + }); + channel = await startFakeChChannel(host, directory); + for (const page of [a, b]) { + await page.locator('[data-native-connection="connected"]').waitFor({ timeout: 5000 }); + await page + .getByText("Recovered authoritative answer", { exact: true }) + .waitFor({ timeout: 3000 }); + } + for (const page of [a, b]) + assert.equal( + await page.getByText("CH response: message from A", { exact: true }).count(), + 1, + ); + assert.equal( + host.requests.filter((request) => request.method === "turn/start").length, + 2, + "Reconnection must not resend commands", + ); + assert.equal( + host.requests.some((request) => request.method === "thread/resume"), + false, + ); + assert.deepEqual(errors, []); + } catch (error) { + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await a.screenshot({ + path: join(process.env.CODEXHOST_TEST_SCREENSHOTS, "realtime-failure.png"), + }); + } + console.error("desktop", (await a.locator("body").innerText()).slice(-6000)); + console.error("mobile", (await b.locator("body").innerText()).slice(-6000)); + console.error("page errors", errors); + throw error; + } + }, +); diff --git a/packages/web-server/test/browser/harness-icons.test.ts b/packages/web-server/test/browser/harness-icons.test.ts new file mode 100644 index 000000000..422cbb894 --- /dev/null +++ b/packages/web-server/test/browser/harness-icons.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { readFileSync, mkdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { it } from "node:test"; +import { chromium } from "playwright"; +import { FakeChHost, startFakeChDebugger } from "../support/ch-host.ts"; +import { startServer } from "../support/server.ts"; + +it( + "uses CH icon presentation in rows and picker across light/dark themes", + { timeout: 60000 }, + async (t) => { + const pi = JSON.parse( + readFileSync(new URL("../../../adapters/pi/manifest.json", import.meta.url), "utf8"), + ); + const claude = JSON.parse( + readFileSync(new URL("../../../adapters/claude-code/manifest.json", import.meta.url), "utf8"), + ); + const host = new FakeChHost(); + host.add("pi-history", "/computer/icons", "Pi history"); + host.add("colored-history", "/computer/icons", "Colored history"); + const original = host.request.bind(host); + host.request = async (method: string, params: Record): Promise => { + if (method === "codexhost/harness/plugins/list") { + const base = await original<{ plugins: Array> }>(method, params); + return { + plugins: [ + { ...base.plugins[0], id: "fake", name: "Pi Fixture", iconStyle: pi.iconStyle }, + { + ...base.plugins[0], + id: "colored", + name: "Colored Fixture", + iconStyle: claude.iconStyle, + }, + { + ...base.plugins[0], + id: "bitmap", + name: "Bitmap Fixture", + iconStyle: { background: "#ffffff", borderRadius: 25, paddingRatio: 0.2 }, + }, + ], + } as T; + } + if (method === "codexhost/thread/ownership/list") + return { + threads: (params.threadIds as string[]).map((id) => ({ + threadId: id, + owner: "external", + harnessId: id === "colored-history" ? "colored" : "fake", + })), + } as T; + return original(method, params); + }; + const debuggerServer = await startFakeChDebugger(host); + t.after(() => debuggerServer.close()); + const url = await startServer( + t, + resolve(import.meta.dirname, "../.."), + ["--import", "tsx", "src/main.ts"], + ["--session-source", "codexhost", "--ch-cdp", debuggerServer.endpoint], + ); + const browser = await chromium.launch(); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + const errors: string[] = []; + let metadataRequests = 0; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (req) => { + if (req.url().endsWith("/harness-icons/presentation.json")) metadataRequests++; + }); + await page.goto(url); + await page.waitForLoadState("networkidle"); + const folder = page.getByRole("treeitem").filter({ hasText: /^icons$/u }); + await folder.waitFor(); + if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click(); + const piRow = page + .locator('[data-row-key="session:pi-history"]') + .getByRole("img", { name: "Pi Fixture", exact: true }), + coloredRow = page + .locator('[data-row-key="session:colored-history"]') + .getByRole("img", { name: "Colored Fixture", exact: true }); + await piRow.locator('svg[viewBox="0 0 24 24"]').waitFor(); + assert.deepEqual( + await piRow + .locator("svg path") + .evaluateAll((paths) => paths.map((path) => path.getAttribute("d"))), + pi.iconStyle.vector.paths.map((path: { d: string }) => path.d), + ); + const seenColors: string[] = []; + for (const theme of ["Dark", "Light"]) { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: theme, exact: true }).click(); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + const appearance = await piRow.locator("svg").evaluate((svg) => ({ + fill: getComputedStyle(svg).fill, + color: getComputedStyle(svg).color, + width: svg.getBoundingClientRect().width, + })); + assert.equal(appearance.fill, appearance.color); + assert.equal(appearance.width, 16); + seenColors.push(appearance.fill); + assert.equal( + await coloredRow.locator("svg").evaluate((svg) => getComputedStyle(svg).fill), + "rgb(217, 119, 87)", + ); + const trigger = page.getByRole("button", { + name: "Select Harness, current Pi Fixture", + exact: true, + }); + await trigger.click(); + const option = page.getByRole("menuitem", { name: "Pi Fixture", exact: true }); + assert.equal( + await option.locator("svg").first().getAttribute("viewBox"), + pi.iconStyle.vector.viewBox, + ); + const bitmap = page + .getByRole("menuitem", { name: "Bitmap Fixture", exact: true }) + .locator("img"); + await bitmap.waitFor(); + assert.deepEqual( + await bitmap.evaluate((image) => ({ + padding: getComputedStyle(image).padding, + background: getComputedStyle(image).backgroundColor, + radius: getComputedStyle(image).borderRadius, + })), + { padding: "4px", background: "rgb(255, 255, 255)", radius: "25%" }, + ); + if (process.env.CODEXHOST_TEST_SCREENSHOTS) { + mkdirSync(process.env.CODEXHOST_TEST_SCREENSHOTS, { recursive: true }); + await page.screenshot({ + path: join( + process.env.CODEXHOST_TEST_SCREENSHOTS, + `harness-icons-${theme.toLowerCase()}.png`, + ), + }); + } + await page.keyboard.press("Escape"); + } + assert.notEqual(seenColors[0], seenColors[1]); + assert.equal(metadataRequests, 1, "Do not fetch metadata per row or picker item"); + assert.deepEqual(errors, []); + }, +); diff --git a/packages/web-server/test/browser/native-presentation.test.ts b/packages/web-server/test/browser/native-presentation.test.ts index 859863e8f..3677cbf19 100644 --- a/packages/web-server/test/browser/native-presentation.test.ts +++ b/packages/web-server/test/browser/native-presentation.test.ts @@ -76,6 +76,9 @@ for (const width of [390, 1280]) .getByRole("button", { name: "New session", exact: true }) .filter({ hasText: "New Session" }) .click(); + // The old bound session has the same Harness trigger label. Wait for + // navigation before opening the new draft's picker, especially in parallel runs. + await page.getByText("What should we build today?", { exact: true }).waitFor(); await page .getByRole("button", { name: "Select Harness, current Fake Harness", exact: true }) .click(); diff --git a/packages/web-server/test/ch-client-gateway.test.ts b/packages/web-server/test/ch-client-gateway.test.ts new file mode 100644 index 000000000..bfdcb56ae --- /dev/null +++ b/packages/web-server/test/ch-client-gateway.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { it } from "node:test"; +import { clientChannelEventSchema } from "@codexhost/shared-contracts"; +import { FakeChChannel, startFakeChChannel } from "./support/ch-channel.ts"; +import { startFakeChDebugger } from "./support/ch-host.ts"; +import { startServer } from "./support/server.ts"; +import { defined } from "./support/defined.ts"; +import { createChHostClient } from "../src/ch-host-client.ts"; + +it( + "exposes the same generic App protocol through Web authentication, without leaking Host credentials", + { timeout: 15000 }, + async (t) => { + const directory = mkdtempSync(join(tmpdir(), "ch-app-gateway-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + await assert.rejects( + createChHostClient({ directory, cdp: "http://127.0.0.1:1" }), + /client channel not found/u, + ); + const host = new FakeChChannel(); + host.add("shared", "/computer/shared"); + const control = await startFakeChChannel(host, directory); + t.after(() => control.close()); + const cdp = await startFakeChDebugger(host); + t.after(() => cdp.close()); + const token = randomBytes(24).toString("base64url"); + const url = await startServer( + t, + resolve(import.meta.dirname, ".."), + ["--import", "tsx", "src/main.ts"], + [ + "--session-source", + "codexhost", + "--ch-cdp", + cdp.endpoint, + "--ch-control-directory", + directory, + "--token", + token, + ], + { authenticated: true }, + ); + const origin = new URL(url).origin; + const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + assert.equal((await fetch(`${origin}/api/ch/v1/events`)).status, 401); + assert.equal( + ( + await fetch(`${origin}/api/ch/v1/events`, { + headers: { ...headers, origin: "http://untrusted.invalid" }, + }) + ).status, + 403, + ); + assert.equal( + ( + await fetch(`${origin}/api/ch/v1/rpc`, { + method: "POST", + headers: { ...headers, origin: "http://untrusted.invalid" }, + body: JSON.stringify({ method: "turn/start", params: {} }), + }) + ).status, + 403, + ); + const snapshot = await fetch(`${origin}/api/ch/v1/snapshot`, { + method: "POST", + headers, + body: JSON.stringify({ threadId: "shared" }), + }); + const text = await snapshot.text(); + assert.equal(snapshot.status, 200); + assert.match(text, /existing CH answer/u); + assert.doesNotMatch(text, /"token"|"port"|"pid"/u); + const controller = new AbortController(); + t.after(() => controller.abort()); + const events = await fetch(`${origin}/api/ch/v1/events`, { + headers, + signal: controller.signal, + }); + assert.equal(events.status, 200); + const reader = defined(events.body).getReader(); + let buffer = ""; + const decoder = new TextDecoder(); + async function next() { + while (!buffer.includes("\n")) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + buffer += decoder.decode(chunk.value); + } + const end = buffer.indexOf("\n"), + line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + return clientChannelEventSchema.parse(JSON.parse(line)); + } + const hello = await next(); + assert.equal(hello.type, "hello"); + const reply = await fetch(`${origin}/api/ch/v1/rpc`, { + method: "POST", + headers, + body: JSON.stringify({ + method: "turn/start", + params: { + threadId: "shared", + clientUserMessageId: "native-app", + input: [{ type: "text", text: "From App protocol" }], + }, + }), + }); + assert.equal(reply.status, 200); + const event = await next(); + assert.equal(event.type, "changed"); + assert.equal(event.cursor.epoch, hello.cursor.epoch); + controller.abort(); + const replayAbort = new AbortController(); + t.after(() => replayAbort.abort()); + const replay = await fetch( + `${origin}/api/ch/v1/events?epoch=${hello.cursor.epoch}&after=${hello.cursor.sequence}`, + { headers, signal: replayAbort.signal }, + ); + const replayReader = defined(replay.body).getReader(); + let frames = ""; + while (!frames.includes('"type":"changed"')) { + const chunk = await replayReader.read(); + assert.equal(chunk.done, false); + frames += new TextDecoder().decode(chunk.value); + } + assert.match(frames, /"reset":false/u); + assert.match(frames, /"type":"changed"/u); + replayAbort.abort(); + assert.equal(host.requests.filter((request) => request.method === "turn/start").length, 1); + }, +); diff --git a/packages/web-server/test/ch-realtime.test.ts b/packages/web-server/test/ch-realtime.test.ts new file mode 100644 index 000000000..2918851ac --- /dev/null +++ b/packages/web-server/test/ch-realtime.test.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { it } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import type { ClientInteraction } from "@codexhost/shared-contracts"; +import { FakeChChannel } from "./support/ch-channel.ts"; +import { defined } from "./support/defined.ts"; +import { ChSessions } from "../src/ch-sessions.ts"; +import { ChThreadView } from "../src/ch-thread-view.ts"; +import { randomUUID } from "node:crypto"; +import { DataDir } from "../src/store.ts"; +import { Workspaces } from "../src/workspaces.ts"; +import { + EventHub, + RpcRegistry, + StreamRegistry, + type StreamHandler, + type StreamSink, +} from "../src/transport.ts"; + +class Streams extends StreamRegistry { + endpoints = new Map(); + override register(name: string, handler: StreamHandler): void { + super.register(name, handler); + this.endpoints.set(name, handler); + } +} +function sink() { + const frames: unknown[] = []; + const cleanup: Array<() => void> = []; + let closed = false; + const sink: StreamSink = { + id: "test", + get closed() { + return closed; + }, + push: (value) => frames.push(value), + end() { + closed = true; + for (const stop of cleanup) stop(); + }, + fail(code, message) { + throw new Error(`${code}: ${message}`); + }, + onClose: (fn) => cleanup.push(fn), + }; + return { frames, sink }; +} +async function wait(check: () => boolean) { + const until = Date.now() + 3000; + while (!check()) { + if (Date.now() > until) throw new Error("Realtime assertion timed out"); + await delay(10); + } +} + +it("coalesces changes, reconciles a change during snapshot read, and stops reading after unfollow", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-realtime-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChChannel(), + row = host.add("thread", root); + const events = new EventHub(root), + sessions = new ChSessions(host, new Workspaces(new DataDir(root), root), events), + rpc = new RpcRegistry(), + streams = new Streams(); + t.after(() => sessions.close()); + sessions.register(rpc, streams); + await rpc.dispatch("session/list", { args: {} }); + const follow = sink(); + await streams.endpoints.get("session/follow")?.( + { request: { address: { sessionId: "thread" } } }, + follow.sink, + ); + let release: () => void = () => {}; + let blocked = false; + host.delaySnapshot = () => + new Promise((resolve) => { + release = resolve; + blocked = true; + }); + host.changed("thread"); + await wait(() => blocked); + row.turns.push({ + id: "live", + status: "inProgress", + items: [ + { type: "userMessage", id: "user-live", content: [{ type: "text", text: "second" }] }, + { type: "agentMessage", id: "agent-live", text: "during read" }, + ], + }); + row.status = { type: "active" }; + for (let n = 0; n < 100; n++) host.changed("thread"); + host.delaySnapshot = undefined; + release(); + await wait(() => JSON.stringify(follow.frames).includes("during read")); + assert.ok(host.snapshots <= 4, `Expected coalesced reads, got ${host.snapshots}`); + const count = host.snapshots; + await delay(1650); + assert.equal(host.snapshots, count, "No 1.5 second history polling in event mode"); + follow.sink.end(); + host.changed("thread"); + await delay(150); + assert.equal(host.snapshots, count); +}); + +it("withdraws cross-client approvals and restores only current pending interactions after reconnect", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-interactions-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChChannel(); + host.add("thread", root); + const approval: ClientInteraction = { + requestId: -42, + threadId: "thread", + kind: "approval", + interaction: { + type: "approval", + title: "Run native tool?", + actions: [ + { id: "once", label: "Allow once", effect: "allowOnce" }, + { id: "deny", label: "Deny", effect: "deny" }, + ], + }, + request: { + method: "mcpServer/elicitation/request", + params: { requestedSchema: { type: "object", properties: {} } }, + }, + }; + host.pending = [approval]; + const events = new EventHub(root), + a = sink(), + b = sink(); + events.handler({}, a.sink); + events.handler({}, b.sink); + const sessions = new ChSessions(host, new Workspaces(new DataDir(root), root), events), + rpc = new RpcRegistry(), + streams = new Streams(); + t.after(() => sessions.close()); + sessions.register(rpc, streams); + await rpc.dispatch("session/list", { args: {} }); + const follow = sink(); + await streams.endpoints.get("session/follow")?.( + { request: { address: { sessionId: "thread" } } }, + follow.sink, + ); + const request = a.frames.find((value) => (value as { type: string }).type === "waterfall") as { + eventId: string; + }; + assert.ok(request); + const clientA = (a.frames[0] as { clientId: string }).clientId; + events.settle({ + clientId: clientA, + eventId: request.eventId, + outcome: { + kind: "returned", + value: { answers: [{ id: "approval", selected: ["Allow once"] }] }, + }, + }); + await wait(() => host.answers.length === 1); + assert.deepEqual(host.answers[0]?.result, { action: "accept", content: {} }); + assert.ok( + b.frames.some( + (value) => + (value as { type: string; eventId?: string }).type === "cancel" && + (value as { eventId?: string }).eventId === request.eventId, + ), + ); + host.pending = [{ ...approval, requestId: -43 }]; + host.changed("thread", "mcpServer/elicitation/request"); + await wait( + () => a.frames.filter((value) => (value as { type: string }).type === "waterfall").length === 2, + ); + host.connection(false); + assert.equal((a.frames.at(-1) as { type: string }).type, "cancel"); + host.pending = []; + host.connection(true); + await delay(200); + assert.equal( + a.frames.filter((value) => (value as { type: string }).type === "waterfall").length, + 2, + ); + assert.equal(host.answers.length, 1, "Disconnect/withdrawal does not answer an interaction"); + follow.sink.end(); +}); + +it("retires in-flight old-epoch snapshots and advances the Web display generation", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-epoch-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChChannel(), + row = host.add("thread", root); + const sessions = new ChSessions( + host, + new Workspaces(new DataDir(root), root), + new EventHub(root), + ), + rpc = new RpcRegistry(), + streams = new Streams(); + t.after(() => sessions.close()); + sessions.register(rpc, streams); + await rpc.dispatch("session/list", { args: {} }); + const before = sink(); + await streams.endpoints.get("session/follow")?.( + { request: { address: { sessionId: row.id } } }, + before.sink, + ); + const oldCursor = (before.frames[0] as { cursor: number }).cursor; + let release: () => void = () => {}; + let blocked = false; + host.delaySnapshot = () => + new Promise((resolve) => { + release = resolve; + blocked = true; + }); + host.changed(row.id); + await wait(() => blocked); + host.connection(false); + host.epoch = randomUUID(); + defined(row.turns[0]).id = "restored"; + host.connection(true); + assert.equal(before.sink.closed, true); + host.delaySnapshot = undefined; + const after = sink(); + await streams.endpoints.get("session/follow")?.( + { request: { address: { sessionId: row.id } } }, + after.sink, + ); + assert.ok((after.frames[0] as { cursor: number }).cursor > oldCursor); + release(); + await delay(150); + assert.equal( + after.frames.filter((value) => (value as { type: string }).type === "snapshot").length, + 1, + ); + assert.ok(JSON.stringify(after.frames).includes("existing CH answer")); + after.sink.end(); +}); + +it("does not turn an acknowledged send into a rejection when the following read fails", async (t) => { + const root = mkdtempSync(join(tmpdir(), "ch-send-ack-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const host = new FakeChChannel(); + host.add("thread", root); + host.delaySnapshot = async () => { + throw new Error("Read unavailable"); + }; + const sessions = new ChSessions( + host, + new Workspaces(new DataDir(root), root), + new EventHub(root), + ), + rpc = new RpcRegistry(); + t.after(() => sessions.close()); + sessions.register(rpc, new StreamRegistry()); + await rpc.dispatch("session/list", { args: {} }); + const result = await rpc.dispatch("session/prompt", { + args: { + request: { + sessionId: "thread", + requestId: "accepted", + content: [{ type: "text", text: "one send" }], + }, + }, + }); + assert.deepEqual(result, { ok: true, value: { accepted: true } }); + await delay(150); + assert.equal(host.requests.filter((request) => request.method === "turn/start").length, 1); +}); + +it("projects running tool output and individual completion without rebroadcasting unchanged controls", () => { + const host = new FakeChChannel(), + row = host.add("thread", "/synthetic"); + row.turns = [ + { + id: "live", + status: "inProgress", + items: [ + { + id: "u", + type: "userMessage", + clientId: "request", + content: [{ type: "text", text: "input" }], + }, + { + id: "command", + type: "commandExecution", + command: "echo live", + status: "inProgress", + aggregatedOutput: "first", + }, + ], + }, + ]; + const controls: unknown[] = []; + const view = new ChThreadView(structuredClone(row), "fake", (...args) => controls.push(args)); + view.update(structuredClone(row)); + assert.deepEqual(view.log.projectionBaseline().values.codexhostToolOutput, { command: "first" }); + const count = controls.length; + view.update(structuredClone(row)); + assert.equal(controls.length, count); + const command = defined(row.turns[0]?.items[1]); + command.aggregatedOutput = "replacement"; + view.update(structuredClone(row)); + assert.deepEqual(view.log.projectionBaseline().values.codexhostToolOutput, { + command: "replacement", + }); + command.status = "completed"; + view.update(structuredClone(row)); + assert.deepEqual(view.log.projectionBaseline().values.codexhostToolOutput, {}); + assert.equal(view.log.events.filter((event) => event.type === "tool/result").length, 1); + view.update(structuredClone(row)); + assert.equal(view.log.events.filter((event) => event.type === "tool/result").length, 1); +}); diff --git a/packages/web-server/test/harness-icon-styles.test.ts b/packages/web-server/test/harness-icon-styles.test.ts new file mode 100644 index 000000000..7bf797837 --- /dev/null +++ b/packages/web-server/test/harness-icon-styles.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { it } from "node:test"; +import { harnessIconStyles } from "../src/harness-icon-styles.ts"; + +it("preserves the Desktop Pi vector and colored/bitmap presentation without importing plugins", () => { + const pi = JSON.parse( + readFileSync(new URL("../../adapters/pi/manifest.json", import.meta.url), "utf8"), + ); + const claude = JSON.parse( + readFileSync(new URL("../../adapters/claude-code/manifest.json", import.meta.url), "utf8"), + ); + const bitmap = { + id: "bitmap", + iconStyle: { background: "#ffffff", borderRadius: 25, paddingRatio: 0.2 }, + }; + const styles = harnessIconStyles([pi, claude, bitmap, { id: "plain" }]); + assert.deepEqual(styles.pi, pi.iconStyle); + assert.equal(styles.pi?.vector?.color, "currentColor"); + assert.deepEqual(styles["claude-code"], claude.iconStyle); + assert.deepEqual(styles.bitmap, bitmap.iconStyle); + assert.equal(styles.plain, null); +}); +it("does not pass arbitrary SVG, CSS URLs or scripts through presentation metadata", () => { + assert.deepEqual( + harnessIconStyles([ + { + id: "svg", + iconStyle: { + vector: { + viewBox: "0 0 24 24", + color: "currentColor", + paths: [{ d: "" }], + }, + }, + }, + { id: "css", iconStyle: { background: "url(https://invalid.example/icon)" } }, + ]), + { svg: null, css: null }, + ); +}); diff --git a/packages/web-server/test/support/ch-channel.ts b/packages/web-server/test/support/ch-channel.ts new file mode 100644 index 000000000..f2b78031e --- /dev/null +++ b/packages/web-server/test/support/ch-channel.ts @@ -0,0 +1,169 @@ +/** Protocol fixture only. Real owner arbitration is tested in host-runtime. */ +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import { + clientThreadSnapshotSchema, + type ClientInteraction, + type ClientChannelResponse, + type ClientThreadSnapshot, +} from "@codexhost/shared-contracts"; +import type { HostClientUpdate } from "@codexhost/desktop-control"; +import { FakeChHost } from "./ch-host.ts"; + +export class FakeChChannel extends FakeChHost { + epoch = randomUUID(); + sequence = 0; + online = true; + snapshots = 0; + listeners = new Set<(event: HostClientUpdate) => void>(); + pending: ClientInteraction[] = []; + answers: ClientChannelResponse[] = []; + delaySnapshot: (() => Promise) | undefined; + readonly realtime = { + request: (method: string, params: Record) => + this.request(method, params), + subscribe: (listener: (event: HostClientUpdate) => void) => { + this.listeners.add(listener); + listener({ type: "connection", online: this.online }); + if (this.online) + listener({ + type: "hello", + version: 1, + cursor: { epoch: this.epoch, sequence: this.sequence }, + reset: true, + }); + return () => { + this.listeners.delete(listener); + }; + }, + snapshot: (id: string) => this.snapshot(id), + respond: (response: ClientChannelResponse) => this.respond(response), + }; + changed(id: string, method = "item/agentMessage/delta"): void { + const event = { + type: "changed" as const, + cursor: { epoch: this.epoch, sequence: ++this.sequence }, + threadId: id, + method, + }; + if (this.online) for (const listener of this.listeners) listener(event); + } + connection(online: boolean): void { + this.online = online; + for (const listener of this.listeners) listener({ type: "connection", online }); + if (online) + for (const listener of this.listeners) + listener({ + type: "hello", + version: 1, + cursor: { epoch: this.epoch, sequence: this.sequence }, + reset: true, + }); + } + async snapshot(id: string): Promise { + this.snapshots++; + if (!this.online) throw new Error("Host offline"); + const row = this.threads.get(id); + if (!row) throw new Error("Unknown Thread"); + const value = clientThreadSnapshotSchema.parse({ + cursor: { epoch: this.epoch, sequence: this.sequence }, + thread: { ...structuredClone(row), turns: [] }, + turnsPage: { + data: structuredClone(row.turns.slice(-5).reverse()), + nextCursor: row.turns.length > 5 ? row.turns.at(-5)?.id : null, + }, + configuration: { + effectiveModel: { id: "native" }, + effectivePermissionModeId: this.modes.get(id) ?? "ask", + }, + interactions: this.pending.filter((value) => value.threadId === id), + }); + await this.delaySnapshot?.(); + return value; + } + async respond(response: ClientChannelResponse): Promise<{ resolved: boolean }> { + if (!this.online) throw new Error("Host disconnected; outcome unknown"); + if (response.epoch !== this.epoch) throw new Error("Host generation changed"); + const index = this.pending.findIndex( + (value) => value.requestId === response.requestId && value.threadId === response.threadId, + ); + if (index !== -1) { + this.pending.splice(index, 1); + this.answers.push(response); + this.changed(response.threadId, "serverRequest/resolved"); + } + return { resolved: true }; + } + override async request(method: string, params: Record): Promise { + const result = await super.request(method, params); + if (method === "turn/start") this.changed(String(params.threadId), "turn/completed"); + if (method === "thread/name/set") this.changed(String(params.threadId), "thread/name/updated"); + return result; + } +} + +export async function startFakeChChannel(host: FakeChChannel, directory: string) { + const token = randomBytes(32).toString("hex"); + const server = createServer((request, response) => { + if (request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(401); + response.end(); + return; + } + if (request.method === "GET" && request.url?.startsWith("/v1/events")) { + response.writeHead(200, { "content-type": "application/x-ndjson" }); + const stop = host.realtime.subscribe((event) => { + if (event.type !== "connection") response.write(JSON.stringify(event) + "\n"); + }); + response.on("close", stop); + return; + } + void (async () => { + let text = ""; + for await (const chunk of request) text += String(chunk); + const input = JSON.parse(text) as Record; + const result = + request.url === "/v1/snapshot" + ? await host.snapshot(String(input.threadId)) + : request.url === "/v1/respond" + ? await host.respond(input as unknown as ClientChannelResponse) + : await host.request(String(input.method), input.params as Record); + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ result })); + })().catch((error: unknown) => { + response.writeHead(400); + response.end( + JSON.stringify({ + error: { message: error instanceof Error ? error.message : String(error) }, + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + mkdirSync(directory, { recursive: true }); + const file = join(directory, `host-${process.pid}.json`); + writeFileSync( + file, + JSON.stringify({ + version: 1, + pid: process.pid, + port: (server.address() as AddressInfo).port, + token, + startedAt: Date.now(), + epoch: host.epoch, + }), + { mode: 0o600 }, + ); + return { + close: async () => { + rmSync(file, { force: true }); + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + }, + }; +} diff --git a/packages/web-server/test/support/ch-host.ts b/packages/web-server/test/support/ch-host.ts index 5644fef1a..b21ec3a33 100644 --- a/packages/web-server/test/support/ch-host.ts +++ b/packages/web-server/test/support/ch-host.ts @@ -162,7 +162,14 @@ export class FakeChHost implements ChHostClient { id, status: "completed", items: [ - { id: id + "-user", type: "userMessage", content: input }, + { + id: id + "-user", + type: "userMessage", + content: input, + ...(typeof params.clientUserMessageId === "string" + ? { clientId: params.clientUserMessageId } + : {}), + }, { id: id + "-agent", type: "agentMessage", diff --git a/packages/web-server/test/support/server.ts b/packages/web-server/test/support/server.ts index f116aeac8..46a4485b1 100644 --- a/packages/web-server/test/support/server.ts +++ b/packages/web-server/test/support/server.ts @@ -19,6 +19,7 @@ export async function startServer( cwd: string, entry: string[], args: string[], + options: { authenticated?: boolean } = {}, ): Promise { const data = mkdtempSync(join(tmpdir(), "codexhost-web-test-")); const child = spawn( @@ -33,7 +34,7 @@ export async function startServer( data, "--workspace", join(data, "workspace"), - "--no-auth", + ...(options.authenticated ? [] : ["--no-auth"]), ...(!args.includes("--session-source") ? ["--session-source", "standalone"] : []), ...args, ], From c0095ba2234b817a5dbed8f521f296117daf2ccf Mon Sep 17 00:00:00 2001 From: BytePioneer-AI <28417416@qq.com> Date: Sat, 10 Oct 2026 20:24:43 +0800 Subject: [PATCH 008/106] fix: restore Web composer command menu Remove the duplicate Host /model descriptor and preserve launcher state across unchanged focus tracking. Project transport attachment availability so unsupported uploads are not advertised. Add shared/standalone menu regressions for desktop and mobile widths. Validated in the working tree: 58 backend tests, 23 browser tests, 4 packed-menu tests, existing typecheck, lint/boundaries, UI build, and read-only live checks. Full migrated frontend strict typechecking remains outside the current root configuration. --- .../api/session-controller/src/types.ts | 4 + .../ui-conversation/src/client/locales.ts | 4 + .../src/client/skeleton/InputBar.tsx | 17 ++- .../ui-input-trigger/src/client/controller.ts | 16 ++- docs/product/standalone-web.md | 2 +- packages/web-server/src/ch-sessions.ts | 10 +- packages/web-server/src/session-log.ts | 3 + .../test/browser/composer-commands.test.ts | 129 ++++++++++++++++++ packages/web-server/test/ch-sessions.test.ts | 11 +- 9 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 packages/web-server/test/browser/composer-commands.test.ts diff --git a/apps/web-ui/packages/api/session-controller/src/types.ts b/apps/web-ui/packages/api/session-controller/src/types.ts index 31fcb0343..85fd608a9 100644 --- a/apps/web-ui/packages/api/session-controller/src/types.ts +++ b/apps/web-ui/packages/api/session-controller/src/types.ts @@ -21,6 +21,8 @@ declare module "@deepseek-ai/dsh-session-projection/types" { sessionListMetadata: SessionListMetadata; /** Host state for the boot-constant image-limit view. */ imageLimits: null; + /** Transport-owned attachment intake availability. */ + attachmentInput: null; /** Durable model selection already used by a request and still pending for a later request. */ modelSelection: ModelSelectionProjectionState; } @@ -29,6 +31,8 @@ declare module "@deepseek-ai/dsh-session-projection/types" { sessionListMetadata: SessionListMetadata; /** Image-intake limits enforced by the Session prompt endpoint. */ imageLimits: ImageAttachmentLimits; + /** Whether this transport accepts attachments, not the native Harness's capabilities. */ + attachmentInput: { readonly enabled: boolean }; /** Durable model selection already used and selected for the next request. */ modelSelection: ModelSelectionProjection; } diff --git a/apps/web-ui/packages/client/ui-conversation/src/client/locales.ts b/apps/web-ui/packages/client/ui-conversation/src/client/locales.ts index 117944856..8be35a571 100644 --- a/apps/web-ui/packages/client/ui-conversation/src/client/locales.ts +++ b/apps/web-ui/packages/client/ui-conversation/src/client/locales.ts @@ -24,6 +24,8 @@ export const zh = { "placeholder.workspace": "选择一个工作区开始", "placeholder.steerQueue": "Cmd/Ctrl+Enter 插话发送全部排队消息", "input.commands": "添加文件或调用指令", + "input.commandsOnly": "调用指令", + "attachment.unavailable": "当前 Web 连接不支持附件上传。", "input.file": "文件", "input.stop": "停止生成", "input.send": "发送消息", @@ -403,6 +405,8 @@ export const en = { "placeholder.workspace": "Choose a workspace to start", "placeholder.steerQueue": "Cmd/Ctrl+Enter steers all queued messages", "input.commands": "Add files or run commands", + "input.commandsOnly": "Run commands", + "attachment.unavailable": "Attachment uploads are unavailable on this Web connection.", "input.file": "File", "input.stop": "Stop generating", "input.send": "Send message", diff --git a/apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bf0ce0ef4..41c5966db 100644 --- a/apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/apps/web-ui/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -134,6 +134,7 @@ export const InputBar = memo(function InputBar({ // The deployment's image-intake limits (absent while no attachment service // is composed — the pre-check below then defers entirely to the host). const imageLimits = useProjection("imageLimits"); + const attachmentsEnabled = useProjection("attachmentInput")?.enabled !== false; // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds @@ -256,6 +257,10 @@ export const InputBar = memo(function InputBar({ const intakeFiles = useCallback( (files: readonly File[], directories?: ReadonlySet): void => { if (subagent !== null || addFiles === undefined || files.length === 0) return; + if (!attachmentsEnabled) { + showToast(t("attachment.unavailable")); + return; + } const rejected = ((): string | null => { if (imageLimits !== undefined) { const mediaTypes = imageLimits.mediaTypes as readonly string[]; @@ -280,10 +285,12 @@ export const InputBar = memo(function InputBar({ })(); if (rejected !== null) showToast(rejected); }, - [subagent, addFiles, attachments, imageLimits, showToast, t], + [subagent, addFiles, attachmentsEnabled, attachments, imageLimits, showToast, t], ); - const canAcceptDrop = subagent === null && !locked && !machineBusy && addFiles !== undefined; + const canAcceptDrop = + attachmentsEnabled && subagent === null && !locked && !machineBusy && addFiles !== undefined; + const commandMenuLabel = t(attachmentsEnabled ? "input.commands" : "input.commandsOnly"); const fileInputRef = useRef(null); const onPickFiles = (e: ChangeEvent): void => { @@ -511,11 +518,11 @@ export const InputBar = memo(function InputBar({ />