diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..c89df410bc 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { - host: { createDirectory(payload: { path: string; name: string }): Promise> } skills: { list(payload: { sessionId: string }): Promise> } settings: { describe(payload: object): Promise }>> @@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessionId = sessions.result.value.items[0]?.sessionId if (sessionId === undefined) throw new Error('workspace adoption created no Session') + // Remote namespaces answer over the same unary carrier; the args object + // keys every wire parameter by its name. + const remote = async (endpoint: string, args: object): Promise => { + const answered = await transport.fetch(`/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`, + method: endpoint, payload: { args }, + }), + }) + const body = await answered.json() as Result + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) + return body.result.value + } const api = transport.createApiClient() const skills = await api.skills.list({ sessionId }) if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) const createDirectory = async (path: string, name: string): Promise => { - const created = await api.host.createDirectory({ path, name }) - if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 683f690e37..9d54d834a9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 2ee2d1b989c4eb25b245cfd528b2fa1ba3bb0272 -capability-seams.zh.md: 7ecda568ef4df4d360414ee872d2b0241ebe1dce +capability-seams.md: 7cf93600cbf0d2deef586135f299058629732685 +capability-seams.zh.md: 3d79717d811fc3cd4e51c19c34c02a4e51d5da21 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2ee2d1b989..7cf93600cb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -217,6 +218,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -348,7 +350,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -459,6 +461,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -506,7 +509,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7ecda568ef..3d79717d81 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -219,6 +220,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -350,7 +352,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -461,6 +463,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -508,7 +511,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a8fb510281..2004935a78 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: f91a64d73af53cc9c57d9d2e795f8f54bcf9eacf +config-catalog.md: 6e001376cfba7c04a07acd56ac733f089283f0bf config-catalog.zh.md: 8b1ab084668ef8e1568801855f9aebc19e76a5e3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f91a64d73a..6e001376cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -796,7 +796,7 @@ export interface Config { } ``` -Source: [`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5e4627d0d4..afb151522f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e28dc23d3baab10a5bffc6014339a6796daa845d -module-graph.zh.md: e32626810d7208cf534a164558dc0fec0dc3bf28 +module-graph.md: 1668d509cea592acc6a6bfd15bb4ba0e5d55d5c8 +module-graph.zh.md: 03edacc1abfe4bfdc0037dd4712e0ce67a014103 diff --git a/docs/module-graph.md b/docs/module-graph.md index e28dc23d3b..1668d509ce 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1083,6 +1083,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1256,6 +1257,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1388,6 +1390,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1398,6 +1401,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1425,7 +1429,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1854,7 +1858,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1877,7 +1881,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1894,11 +1898,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e32626810d..03edacc1ab 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1085,6 +1085,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1258,6 +1259,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1390,6 +1392,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1400,6 +1403,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_sidebar pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1427,7 +1431,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1856,7 +1860,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1879,7 +1883,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1896,11 +1900,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index a41ab20b51..074fcdd610 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: af2a8d4c5abbde04764e26751ba178fa99b2b3ef -workspace.zh.md: f54444f052c8faffc7670468d5423123cd3aeeac +workspace.md: 82bbe310d490653bae5d88f41d1fcbe76833b431 +workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index af2a8d4c5a..82bbe310d4 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.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. + +```ts cordis-catalog +/** + * 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 + +/** + * 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 + +/** + * 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 +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index f54444f052..9bf63b7a90 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.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. + +```ts cordis-catalog +/** + * 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 + +/** + * 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 + +/** + * 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 +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/knip.json b/knip.json index e5e331194b..b08fe22a25 100644 --- a/knip.json +++ b/knip.json @@ -76,11 +76,6 @@ "tests/**/*.ts" ] }, - "packages/api/workspace-controller": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/client/ui-approval": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 14be075879..473abfbe88 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -53,7 +53,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' */ export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, + CredentialView, DiscoveredModelView, IApiClient, MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 1625ac65e7..98d7196358 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -167,23 +167,9 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -210,9 +196,6 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index eee1d5bd5a..3f837582cb 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: d2f89e6c9f0118be9150650c1c6dec859375f990 -README.zh.md: 3411ca0642df7fa7f8652d1863905868f54cf4bd +README.md: 731a6331e2a19991921c022759f6bbd971cef525 +README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index d2f89e6c9f..731a6331e2 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. +`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. The package also owns `ctx.directoryPickerController` and the generated `ctx.remote.directoryPicker` namespace, because the directory-picking seam it carries is abstract and never a Loader entry of its own. ## Table of Contents diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 3411ca0642..f46c78b7b3 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。本包同时拥有 `ctx.directoryPickerController` 与生成的 `ctx.remote.directoryPicker` namespace,因为它承载的选目录 seam 是抽象的,自身从不作为 Loader entry。 ## 目录 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 29245fef86..bc045f38c7 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", @@ -87,6 +88,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 0000000000..ee58ef7b83 --- /dev/null +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -0,0 +1,171 @@ +/** + * 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 } 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, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { DirectoryPickerErrorDetailsMap } from './types.ts' + +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 pickerFailureOf( + '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 pickerFailureOf( + 'directory-picker-unavailable', + `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, + { capability: capability.kind }, + ) + } + return capability as DirectoryPickerCapabilities[Kind] + } +} + +/** + * Raise one entry of the picking wire failure vocabulary. + * @param code - the failure code a caller discriminates on. + * @param message - operator-facing description. + * @param details - the payload this code carries. + * @returns the failure to throw across the Remote boundary. + */ +function pickerFailureOf( + code: Code, + message: string, + details: DirectoryPickerErrorDetailsMap[Code], +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +/** + * 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): TypertRemoteFailure { + if (error instanceof DirectoryPickerError) { + return pickerFailureOf(error.code, error.message, { path: error.path }) + } + return pickerFailureOf('internal', errorMessage(error), {}) +} + +/** + * Classify a cancellable primitive's rejection. An abort is the caller's own + * timeout or disconnect, not a backend failure, so it answers `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, +): TypertRemoteFailure { + if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {}) + if (failed === undefined) return browseFailure(error) + return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {}) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0fbd514cd5..40ab0647f2 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' +import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed } from './feed.ts' import type { WorkspaceArchiveSessionRequest, @@ -20,6 +21,7 @@ import type { } from './types.ts' export type * from './types.ts' +export { DirectoryPickerController } from './directory-picker.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -40,6 +42,11 @@ export class WorkspaceController extends TypertRemoteService { super(ctx, 'workspaceController', { namespace: 'workspace' }) 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) } /** diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f530e2ef12..552a9e20c3 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -1,9 +1,18 @@ -/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */ +/** + * 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 { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { z as zCore } from 'zod' + +type ZodIssue = zCore.core.$ZodIssue export type { WorkspaceId } 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 { @@ -43,6 +52,24 @@ export type WorkspaceError = { } }[keyof WorkspaceErrorDetailsMap] +/** Stable directory-picking failure details returned by the picking wire verbs. */ +export interface DirectoryPickerErrorDetailsMap { + /** The directory creation request violates its semantic input constraints. */ + 'bad-request': { readonly issues: ZodIssue[] } + /** 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-unreadable': { readonly path: string } + /** A child of that name is already there. */ + 'directory-exists': { readonly path: string } + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + 'directory-create-failed': { readonly path: string } + /** The caller's own timeout or disconnect ended the chooser or the scan. */ + cancelled: Record + /** A backend failure with no seam code of its own. */ + internal: Record +} + /** Existing directory requested for Workspace adoption. */ export interface WorkspaceCreateRequest { readonly path: string diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts new file mode 100644 index 0000000000..32fd2dd6a1 --- /dev/null +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { DirectoryPickerController } from '../src/directory-picker.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +/** A backend serving exactly the capability one case is about. */ +class StubPicker extends DirectoryPicker { + static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + + capability(): DirectoryPickerCapability { + return StubPicker.capabilityStub + } +} + +const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') { + throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + } + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') { + throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + } + if (name === 'unwritable') throw new Error('disk detached') + if (name === 'gone') throw 'the volume vanished' + return `${path}/${name}` + }, +} + +async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) { + StubPicker.capabilityStub = capability + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(StubPicker).await() + return new DirectoryPickerController(ctx) +} + +/** The failure payload a refused wire verb carries. */ +async function refused(call: Promise): Promise<{ code: string; message: string; details: object }> { + try { + await call + } catch (error: unknown) { + if (!(error instanceof TypertRemoteFailure)) throw error + return { ...error.failure } + } + throw new Error('the call was expected to be refused') +} + +describe('directoryPicker pick Remote', () => { + it('answers the selected path or the operator\'s cancellation', async () => { + const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) + expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project') + + const cancelled = await harness(NATIVE_STUB) + expect(await cancelled.pick(new AbortController().signal)).toBeNull() + }) + + it('reports an aborted chooser as cancelled and any other failure as internal', async () => { + const picker = await harness({ + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = refused(picker.pick(abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + + const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) + const failure = await refused(broken.pick(new AbortController().signal)) + expect(failure.code).toBe('internal') + expect(failure.message).toContain('no chooser installed') + }) + + it('refuses the native verb under a browse composition', async () => { + const picker = await harness(BROWSE_STUB) + const failure = await refused(picker.pick(new AbortController().signal)) + expect(failure.code).toBe('directory-picker-unavailable') + expect(failure.message).toContain('needs the native capability') + expect(failure.details).toEqual({ capability: 'browse' }) + }) +}) + +describe('directoryPicker browse Remotes', () => { + it('serves listings and creation, defaulting to the home directory', async () => { + const picker = await harness(BROWSE_STUB) + const signal = new AbortController().signal + expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' }) + expect(await picker.list('/home/user/projects', signal)) + .toMatchObject({ path: '/home/user/projects' }) + expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh') + }) + + it('maps the seam\'s typed failures and folds unknown throws to internal', async () => { + const picker = await harness(BROWSE_STUB) + expect(await refused(picker.list('/denied', new AbortController().signal))) + .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } }) + expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists') + expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal') + + const thrown = await refused(picker.createDirectory('/home/user', 'gone')) + expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' }) + }) + + it('rejects invalid child names before capability dispatch', async () => { + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + const picker = await harness({ + kind: 'browse', + list: (path, signal) => BROWSE_STUB.list(path, signal), + createDirectory, + }) + + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + const failure = await refused(picker.createDirectory('/home/user', name)) + expect(failure).toMatchObject({ + code: 'bad-request', + message: 'invalid payload for host.createDirectory', + }) + expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true) + } + expect(createDirectory).not.toHaveBeenCalled() + }) + + it('reports an aborted listing as cancelled', async () => { + const picker = await harness({ + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = refused(picker.list(undefined, abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + }) + + it('refuses the browse verbs under a native composition', async () => { + const picker = await harness() + expect(await refused(picker.list(undefined, new AbortController().signal))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + expect(await refused(picker.createDirectory('/x', 'y'))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index 46d392dff3..9979b769f6 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -17,6 +17,7 @@ { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../typert/protocol" }, { "path": "../../workspace/workspace" } ] diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 76c584fd01..4b98892781 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,11 +10,13 @@ "src/invariant.ts", "src/types.ts", "src/commands.ts", + "src/directory-picker.ts", "src/feed.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 5c7e53bb70..395e443ea3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 3a3c78dfa5..113e72212c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,6 @@ export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, ResponseValue, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f3a20282ed..c3bb6b503d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,6 +29,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' +import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, @@ -2178,6 +2179,55 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } + /** + * Canonical fixture implementation of the generated Directory Picker Remote + * contract. The pick is deterministic — the keyless lanes drive the full + * pick-then-adopt path without an OS chooser — over the same design-mock + * tree the browse primitives serve. + */ + const directoryPickerRemotes = { + pick(): ConnectionRpcResult { + return { ok: true, value: `${FIXTURE_HOME}/Documents/project` } + }, + list(path?: string): ConnectionRpcResult { + const target = path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return { + ok: false, + error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + } + } + return { + ok: true, + value: { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }, + } + }, + createDirectory(parent: string, name: string): ConnectionRpcResult { + const children = childrenOf(parent) + if (children === undefined) { + return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + } + // Same root special case as list's entry paths: a plain join under '/' + // would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${name}` : `${parent}/${name}` + if (children.includes(name)) { + return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + } + directoryTree.set(parent, [...children, name]) + directoryTree.set(target, []) + return { ok: true, value: target } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -3264,42 +3314,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), - // Deterministic native pick: the keyless lanes drive the full - // pick-then-adopt path without an OS chooser (design-mock content, - // same tree the browse primitives serve). - pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), - listDirectory: (request) => { - const target = request.payload.path ?? FIXTURE_HOME - const children = childrenOf(target) - if (children === undefined) { - return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) - } - return ok(request, { - path: target, - home: FIXTURE_HOME, - crumbs: crumbsOf(target), - entries: [...children].sort((a, b) => a.localeCompare(b)) - .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), - // The fixture tree is tiny; no level ever reaches a backend bound. - truncated: false, - }) - }, - createDirectory: (request) => { - const parent = request.payload.path - const children = childrenOf(parent) - if (children === undefined) { - return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) - } - // Same root special case as listDirectory's entry paths: a plain join - // under '/' would mint '//name' and fork the tree's identity. - const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` - if (children.includes(request.payload.name)) { - return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) - } - directoryTree.set(parent, [...children, request.payload.name]) - directoryTree.set(target, []) - return ok(request, { path: target }) - }, openPath: request => ok(request, { opened: true as const }), }, agentPresets: { @@ -3425,6 +3439,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentId: SessionId line?: string query?: string + path?: string + name?: string images?: readonly unknown[] ref?: { id: string; revision: number } agentPreset?: string @@ -3442,6 +3458,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) + case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick()) + case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path)) + case 'directoryPicker/createDirectory': + return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: (request as { objective?: string } | undefined)?.objective as string, ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined @@ -3596,9 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient { ): Promise> { switch (method) { case 'host.describe': return this.api.host.describe(request) - case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) - case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) - case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 358b2f41cc..8136254f1c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -31,7 +31,6 @@ declare module '@deepseek-ai/cordis' { // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 93ab3af3b0..c4bd0d03ff 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -50,30 +50,13 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly generationConns: StreamConn[] = [] readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index f17af5b978..b69d775510 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -17,8 +17,9 @@ import { type FixtureOptions, } from '../src/client/fixture.ts' import type { - ClientConnectionRpc, + ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -286,6 +287,12 @@ interface FixtureRemoteEventStream extends AsyncIterable['api'] & { + /** The directory-picking Remote namespace as the fixture serves it. */ + readonly directoryPickerRemote: { + pick: () => Promise> + list: (path?: string) => Promise> + createDirectory: (path: string, name: string) => Promise> + } readonly sessions: FixtureSessionApi readonly sessionRemote: FixtureSessionRemote readonly workspace: FixtureWorkspaceApi @@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType['api'] & { function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { const { api, rpc } = createFixtureFaces(options) return Object.assign(api, { + directoryPickerRemote: { + pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as + Promise>, + list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as + Promise>, + createDirectory: (path: string, name: string) => + rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as + Promise>, + }, sessions: createSessionApi(rpc), sessionRemote: createSessionRemote(rpc), workspace: createWorkspaceApi(rpc), @@ -1049,18 +1065,18 @@ describe('createFixtureApi', () => { it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() - const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) - if (!created.result.ok) throw new Error('create failed') - expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.crumbs).toEqual([ + const created = await api.directoryPickerRemote.createDirectory('/', 'srv') + if (!created.ok) throw new Error('create failed') + expect(created.value).toBe('/srv') + const listed = await api.directoryPickerRemote.list('/srv') + if (!listed.ok) throw new Error('list failed') + expect(listed.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) - if (!root.result.ok) throw new Error('root list failed') - expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + const root = await api.directoryPickerRemote.list('/') + if (!root.ok) throw new Error('root list failed') + expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 1b45ecc014..2e267ac966 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -35,11 +35,11 @@ describe('HTTP bridge abort', () => { it('aborts a pending native picker request when the browser disconnects', async () => { const body = JSON.stringify({ - type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} }, }) const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage Object.assign(request, { - url: '/api/host.pickDirectory', + url: '/api/directoryPicker/pick', method: 'POST', headers: { 'content-type': 'application/json' }, }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index e1cb7abe83..1cead76b09 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -174,7 +174,7 @@ describe('connection node half', () => { it('requires the same browser session for every method on every trusted authority', async () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', ] @@ -503,7 +503,7 @@ describe('connection node half over a real HTTP server', () => { const methods = [ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'llm.discoverModels', 'agentPreset.openDocument', 'llm.providers', 'llm.models', diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index c40e2cbadb..cb409edde0 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -36,6 +36,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/directory-picker" + }, { "path": "../../interaction/commands" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 9c134a13ff..2d0003c1e8 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" @@ -49,7 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -57,7 +57,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 571ab4f2a9..d96e513c34 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,7 +40,7 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index dbe80e9415..e532eaf762 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 8730d48098..9e7b2833ea 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -2,10 +2,11 @@ * Browser half of the browse directory-picker backend: fills ui-workspace's * two directory-flow holes with the in-app Select Workspace Directory dialog * (figma `Harness` 813-23126 family), driving the node half's - * `host.listDirectory`/`host.createDirectory` primitives. Mounting this - * package therefore composes both sides of the browse interaction with one - * cordis.yml row; no client code branches on a capability kind. The dialog's - * copy is locale-registered here — the flow package owns its own strings. + * `directoryPicker/list`/`directoryPicker/createDirectory` primitives. + * Mounting this package therefore composes both sides of the browse + * interaction with one cordis.yml row; no client code branches on a + * capability kind. The dialog's copy is locale-registered here — the flow + * package owns its own strings. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 6008e58ddc..f98d959478 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 69a624eb38..5f386c8723 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index 24535dac3f..aaa77a76d9 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -18,7 +18,7 @@ "path": "../ui-primitives" }, { - "path": "../connection/tsconfig.client.json" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../locale" diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 951efc2356..68589c2982 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,7 +1,7 @@ /** * Browser half of the native directory-picker backend: fills ui-workspace's * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and * reporting the one outcome — picked path, cancellation, or failure — back * through the owner conversation. Mounting this package therefore composes * both sides of the native interaction with one cordis.yml row; no client diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index a50f80911e..b009ef95eb 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", @@ -53,6 +54,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -62,11 +64,13 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -80,6 +84,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index da06e95cc9..7f87202365 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -59,7 +59,9 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = [ + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', +] /** * Register the browser and picker once their slot declarations are on the @@ -72,7 +74,8 @@ export function apply(ctx: Context): void { const sessions = ctx.get('sessions') as ISessions const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription - const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions) + const uiWorkspace = new UiWorkspaceService( + ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1135f60b2e..4d0f3e3fa9 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,9 +1,9 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { - DirectoryListing, IApiClient, RpcError, -} from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { ISessions, SessionListState, @@ -69,7 +69,7 @@ export class DirectoryBrowseError extends Error { override readonly name = 'DirectoryBrowseError' /** @param rpcError - Host directory business failure. */ - constructor(readonly rpcError: RpcError) { + constructor(readonly rpcError: RemoteFailure) { super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) } } @@ -81,12 +81,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { /** * @param ctx - Client root Context. * @param api - shared Host API carrier. + * @param directoryPicker - the directory-picking Remote namespace. * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. */ constructor( ctx: Context, private readonly api: IApiClient, + private readonly directoryPicker: ClientRemote['directoryPicker'], private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, ) { @@ -144,23 +146,21 @@ class UiWorkspaceService extends Service implements UiWorkspace { } async pickDirectory(): Promise { - const response = await this.api.host.pickDirectory({}) - if (!response.result.ok) { - throw new Error(`directory picker failed: ${response.result.error.message}`) - } - return response.result.value.path + const result = await this.directoryPicker.pick() + if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`) + return result.value } async listDirectory(path?: string, signal?: AbortSignal): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value + const result = await this.directoryPicker.list(path, signal) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async createDirectory(path: string, name: string): Promise { - const response = await this.api.host.createDirectory({ path, name }) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value.path + const result = await this.directoryPicker.createDirectory(path, name) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async openPath(path: string): Promise { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 79aef72717..3834358288 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -60,6 +61,10 @@ async function bench() { ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) + const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) + const directoryPicker = { pick: pickDirectory } + Object.assign(new TestRemote(ctx), { directoryPicker }) + ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(ctx) // These specs assert the shipped Chinese copy. There is no jsdom `window` // in this lane, so browser-language detection never runs and the locale @@ -68,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, + insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, } } @@ -83,7 +88,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', ]) }) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 7db2ebd545..cc698e7391 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -37,6 +37,11 @@ async function createRuntime(): Promise { runtime.ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) + // The rename flow never picks a directory; the namespace only has to be there + // for ui-workspace's inject to settle. + const directoryPicker = {} + Object.assign(new TestRemote(runtime.ctx), { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 875709a409..95d113bbfd 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -8,11 +8,12 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import { RpcId, - type DirectoryListing, type IApiClient, type RpcError, type RpcResponse, } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -180,9 +181,6 @@ class FakeApiClient implements IApiClient { home: '/home/u', canOpenPath: true, })) - onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null })) - onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing)) - onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) declare readonly skills: IApiClient['skills'] @@ -193,9 +191,6 @@ class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), - pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)), - listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)), - createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)), openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), } @@ -209,6 +204,32 @@ class FakeApiClient implements IApiClient { } } +/** The directory-picking Remote namespace, recorded and scripted per case. */ +class FakeDirectoryPicker { + readonly calls: { method: string; payload: unknown }[] = [] + + onPick: () => Promise> = () => Promise.resolve({ ok: true, value: null }) + onList: () => Promise> = () => Promise.resolve({ ok: true, value: listing }) + onCreateDirectory: () => Promise> = + () => Promise.resolve({ ok: true, value: '/home/u/new' }) + + readonly remote: ClientRemote['directoryPicker'] = { + pick: () => this.record('pick', {}, this.onPick()), + list: (path?: string) => this.record('list', { path }, this.onList()), + createDirectory: (path: string, name: string) => + this.record('createDirectory', { path, name }, this.onCreateDirectory()), + } + + callsOf(method: string): unknown[] { + return this.calls.filter(call => call.method === method).map(call => call.payload) + } + + private record(method: string, payload: unknown, result: Promise): Promise { + this.calls.push({ method, payload }) + return result + } +} + interface BenchOptions { readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState @@ -217,15 +238,17 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() const api = new FakeApiClient() + const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) const uiWorkspace = new UiWorkspaceService( ctx, api, + directoryPicker.remote, workspaces, sessions as unknown as ISessions, ) - return { api, ctx, sessions, uiWorkspace, workspaces } + return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces } } async function flush(): Promise { @@ -451,31 +474,33 @@ describe('UiWorkspaceService', () => { it('passes directory operations to the Host and preserves structured browse failures', async () => { const b = bench() - b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha') - b.api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull() - expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}]) await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing) await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing) - expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }]) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') - expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) + expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) - b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} })) + b.directoryPicker.onPick = () => Promise.resolve({ + ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') - b.api.onListDirectory = () => Promise.resolve(failed({ - code: 'directory-unreadable', message: 'denied', details: { path: '/private' }, - })) + b.directoryPicker.onList = () => Promise.resolve({ + ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) - b.api.onCreateDirectory = () => Promise.resolve(failed({ - code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' }, - })) + b.directoryPicker.onCreateDirectory = () => Promise.resolve({ + ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' }, }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 5d7708e031..398f9c2128 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../connection/tsconfig.client.json" }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4f5da0ccc8..e90a33af0b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -768,6 +768,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'directoryPickerController', + summary: 'Host service backing the generated `ctx.remote.directoryPicker` namespace.', + description: '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.', + methods: [ + { + signature: '@Remote(\'pick\') async pick(signal: AbortSignal): Promise', + description: 'Open the host\'s OS chooser for a Remote caller.', + parameters: [{ name: 'signal', description: 'caller lifetime; abort terminates the chooser.' }], + returns: 'the chosen absolute path, or null when the operator cancels.', + }, + { + signature: '@Remote(\'list\') async list(path: string | undefined, signal: AbortSignal): Promise', + description: 'List one directory level for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the home directory.' }, { name: 'signal', description: 'caller lifetime; abort stops the backend\'s scan instead of letting it outlive a disconnected caller.' }], + returns: 'the level\'s listing with its ancestry.', + }, + { + signature: '@Remote(\'createDirectory\') async createDirectory(path: string, name: string): Promise', + description: 'Create one child directory for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }], + returns: 'the created directory\'s absolute path.', + }, + ], + }, { key: 'e2b', summary: 'Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout or disposal.', @@ -3660,6 +3685,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DirectoryEntry', + declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}', + }, + { + name: 'DirectoryListing', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', + }, { name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', @@ -4422,7 +4455,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 209e601d83..db9c6a286b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 3460d0ec021f658fe91e1ed8b617196d437cb937 -README.zh.md: a6cfac365fe728f26b3afd22ef32e661bd0146ff +README.md: 9f96999faf26090ccb58763a9301381a4d21d2de +README.zh.md: 3633880a27e23c2c35706d97b8c905f24a17882d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3460d0ec02..9f96999faf 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. +The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. ### Sessions and history @@ -140,7 +140,6 @@ These limits define where the gateway is a poor fit; they are current package co - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service; a carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. -- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). - **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index a6cfac365f..3633880a27 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 +API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 ### 会话与历史 @@ -140,7 +140,6 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q - **预留 seam 不进入 `RpcMethodMap`**——`prompt.mode: 'inject'`、`job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**——网关是单用户本地服务;将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 -- **Linux 原生选择器依赖桌面工具**——在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是浏览后端(见 [native 后端 README](../directory-picker-native/README.zh.md))。 - **冷列表提示只向“保持可见、排序偏旧”降级**——projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,除非符合资格的小工件提供精确折叠。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)的范围。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c3f8210d6..fc94e58035 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,7 +41,6 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Read live abort state across awaits without treating it as synchronously immutable. */ @@ -59,14 +58,6 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } -/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ -function directoryError(error: unknown): RpcError { - if (error instanceof DirectoryPickerError) { - return { code: error.code, message: error.message, details: { path: error.path } } - } - return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } -} - /** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { /** Current deployment model selection reported by `host.describe`. */ @@ -290,72 +281,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - async pickDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'native') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - const path = await capability.pick(signal) - return ok(request, { path }) - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'directory picker was aborted', - details: {}, - }) - } - return err(request, { - code: 'internal', - message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) - } - }, - - async listDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - // The carrier's signal follows the caller: a disconnect or timeout - // stops the backend's directory scan instead of outliving it. - return ok(request, await capability.list(request.payload.path, signal)) - } catch (error: unknown) { - // An abort is the caller's own timeout/disconnect, not a server failure. - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) - } - return err(request, directoryError(error)) - } - }, - - async createDirectory(request) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) - } catch (error: unknown) { - return err(request, directoryError(error)) - } - }, - async openPath(request, signal) { return openPath(request, request.payload.path, signal) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 1a520f1e30..6735e55639 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,7 +3,6 @@ */ import { z } from 'zod' -import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' @@ -21,49 +20,6 @@ export const hostDescribeValueSchema = z.object({ canOpenPath: z.boolean(), }) satisfies z.ZodType>> -/** host.pickDirectory request payload (empty object literal). */ -export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> - -/** host.pickDirectory response value; null means the user cancelled. */ -export const hostPickDirectoryValueSchema = z.object({ - path: z.string().nullable(), -}) satisfies z.ZodType>> - -/** Directory row shared by listing entries and breadcrumb crumbs. */ -export const directoryEntrySchema = z.object({ - name: z.string(), - path: z.string(), - hidden: z.boolean(), -}) satisfies z.ZodType> - -/** host.listDirectory request payload; an absent path lists the home directory. */ -export const hostListDirectoryRequestSchema = z.object({ - path: z.string().optional(), -}) satisfies z.ZodType>> - -/** host.listDirectory response value. */ -export const hostListDirectoryValueSchema = z.object({ - path: z.string(), - home: z.string(), - crumbs: z.array(directoryEntrySchema), - entries: z.array(directoryEntrySchema), - truncated: z.boolean(), -}) satisfies z.ZodType>> - -/** host.createDirectory request payload: name must be one plain path segment. */ -export const hostCreateDirectoryRequestSchema = z.object({ - path: z.string(), - name: z.string(), -}).refine( - payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..' - && !/[/\\]/.test(payload.name), - { message: 'host.createDirectory requires a single non-blank path segment name' }, -) satisfies z.ZodType>> - -/** host.createDirectory response value: the created directory's absolute path. */ -export const hostCreateDirectoryValueSchema = z.object({ - path: z.string(), -}) satisfies z.ZodType>> /** host.openPath request payload. */ export const hostOpenPathRequestSchema = z.object({ path: z.string().min(1), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index e700b86db3..ceb7998d2b 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,33 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' -/** One directory row of a listing: a child entry or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — the client never joins path segments itself. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** host.listDirectory response value: one directory level plus its ancestry. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ - truncated: boolean -} - /** Host-level unary methods. */ export interface HostApi { /** @@ -54,37 +27,6 @@ export interface HostApi { canOpenPath: boolean }>> - /** - * Open the operating system's single-directory picker; cancellation returns - * null. Only served under the `native` capability. - */ - pickDirectory( - request: RpcRequest<{}>, - signal: AbortSignal, - ): Promise> - - /** - * List one directory level for the in-app browser; an absent path lists the - * host account's home directory. Only served under the `browse` capability; - * unreadable or missing targets fail with `directory-unreadable`. The - * carrier's request signal follows the caller, stopping the backend's scan - * on disconnect or timeout. - */ - listDirectory( - request: RpcRequest<{ path?: string }>, - signal: AbortSignal, - ): Promise> - - /** - * Create one child directory under an existing parent (the browser's - * "New folder"). Only served under the `browse` capability; an existing - * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. - */ - createDirectory( - request: RpcRequest<{ path: string; name: string }>, - ): Promise> - /** * Open a filesystem path with the operating system's default application * (Finder / Explorer / xdg-open hand-off). The browser carrier's diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 434c362e1a..b151677137 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,7 +29,7 @@ export type { ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' -export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' +export type { HostApi } from './host.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index d636963e82..2590b4cec7 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -18,9 +18,6 @@ import type { RpcResponse } from './rpc.ts' */ export interface RpcMethodMap { 'host.describe': HostApi['describe'] - 'host.pickDirectory': HostApi['pickDirectory'] - 'host.listDirectory': HostApi['listDirectory'] - 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 498c7c3a1f..57ca87bcf3 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -36,10 +36,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index b154a5e14a..7448ef669c 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -31,10 +31,6 @@ export interface RpcErrorDetailsMap { 'cancelled': {} 'session-not-found': { sessionId: SessionId } 'invalid-time-zone': { value: string } - 'directory-unreadable': { path: string } - 'directory-exists': { path: string } - 'directory-create-failed': { path: string } - 'directory-picker-unavailable': { capability: string } 'agent-preset-read-only': { agentPreset: string; reason: string } 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index f65d5fafff..4a567f8638 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,8 +13,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { serverResponseSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryValueSchema, hostDescribeValueSchema, - hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, + hostDescribeValueSchema, hostOpenPathValueSchema, } from '../api/host.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { @@ -44,9 +43,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> - listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>> - createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } skills: { @@ -80,9 +76,6 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'host.describe': hostDescribeValueSchema, - 'host.pickDirectory': hostPickDirectoryValueSchema, - 'host.listDirectory': hostListDirectoryValueSchema, - 'host.createDirectory': hostCreateDirectoryValueSchema, 'host.openPath': hostOpenPathValueSchema, 'skill.list': skillListValueSchema, 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, @@ -102,9 +95,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */ + /** @param timeoutMs - timeout for unary calls. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -178,24 +168,21 @@ export abstract class AbstractApiClient implements IApiClient { /** * Shared POST leg of unary calls: JSON body, - * optional default timeout merged with the caller's external signal, non-2xx → transport throw. + * default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, body: ClientRequest, signal: AbortSignal | undefined, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { - const requestSignal = timeoutPolicy === 'default' - ? signal === undefined - ? AbortSignal.timeout(this.timeoutMs) - : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) - : signal + const requestSignal = signal === undefined + ? AbortSignal.timeout(this.timeoutMs) + : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) const response = await this.doFetch(new URL(path, this.resolveBase()), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - ...requestSignal === undefined ? {} : { signal: requestSignal }, + signal: requestSignal, }) if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) return response @@ -210,11 +197,10 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy) + const response = await this.postJson(`/api/${method}`, message, signal) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -229,13 +215,6 @@ export abstract class AbstractApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), - // A native system dialog is user-paced and may legitimately stay open - // longer than the normal unary deadline. Caller/connection aborts remain. - pickDirectory: (payload, signal) => this.callUnary( - 'host.pickDirectory', payload, signal, 'caller-signal-only', - ), - listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), - createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 6b3a7d3b13..d4f3ea58ed 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -15,9 +15,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, - hostListDirectoryRequestSchema, hostOpenPathRequestSchema, - hostPickDirectoryRequestSchema, + hostDescribeRequestSchema, hostOpenPathRequestSchema, } from '../api/host.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { @@ -50,9 +48,6 @@ type UnaryRoutes = { const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, - 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, - 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, - 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index aab8e952d2..98a0e091c8 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-host-directory-picker' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' import { diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts index 74c068150f..681f2d0ed4 100644 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -2,8 +2,6 @@ import { homedir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '../src/api-proxy.ts' @@ -26,7 +24,6 @@ function expectOk(response: { readonly result: { readonly ok: true; readonly } async function harness( - picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise canOpenPath?: () => boolean @@ -35,7 +32,6 @@ async function harness( const ctx = new Context() contexts.push(ctx) await ctx.plugin(AgentRegistry) - ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: '/tmp/dsh-apiproxy-host', @@ -45,135 +41,10 @@ async function harness( return { api } } -describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the native capability', async () => { - const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) - expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: '/tmp/project' } }) - - const cancelled = await harness({ kind: 'native', pick: async () => null }) - expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) - .toEqual({ ok: true, value: { path: null } }) - }) - - it('propagates abort into the native capability as a cancelled RPC error', async () => { - const { api } = await harness({ - kind: 'native', - pick: signal => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - }), - }) - const abort = new AbortController() - const pending = api.host.pickDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('folds a non-abort native-chooser failure into an internal error', async () => { - const { api } = await harness({ - kind: 'native', - pick: async () => { throw new Error('no chooser installed') }, - }) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('refuses the native RPC under a browse composition', async () => { - const { api } = await harness(BROWSE_STUB) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, - }) - }) -}) - -const BROWSE_STUB: DirectoryPickerCapability = { - kind: 'browse', - list: async (path) => { - if (path === '/denied') { - throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') - } - const target = path ?? '/home/user' - return { - path: target, - home: '/home/user', - crumbs: [{ name: '/', path: '/', hidden: false }], - entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], - truncated: false, - } - }, - createDirectory: async (path, name) => { - if (name === 'taken') { - throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') - } - if (name === 'unwritable') throw new Error('disk detached') - return `${path}/${name}` - }, -} - -describe('host.listDirectory / host.createDirectory', () => { - it('serves listings and creation through the browse capability, defaulting to home', async () => { - const { api } = await harness(BROWSE_STUB) - const home = await api.host.listDirectory(request({}), new AbortController().signal) - expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory( - request({ path: '/home/user/projects' }), - new AbortController().signal, - ) - expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) - const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) - expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) - }) - - it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => { - const { api } = await harness(BROWSE_STUB) - expect((await api.host.listDirectory( - request({ path: '/denied' }), - new AbortController().signal, - )).result).toMatchObject({ - ok: false, - error: { code: 'directory-unreadable', details: { path: '/denied' } }, - }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result) - .toMatchObject({ ok: false, error: { code: 'directory-exists' } }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result) - .toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('reports an aborted listing as cancelled', async () => { - const { api } = await harness({ - kind: 'browse', - list: (_path, signal) => new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) - }), - createDirectory: async () => '/never', - }) - const abort = new AbortController() - const pending = api.host.listDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('refuses the browse RPCs under a native composition', async () => { - const { api } = await harness() - expect((await api.host.listDirectory(request({}), new AbortController().signal)).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - }) -}) - describe('host.openPath', () => { it('describes whether the deployment can reach a native desktop', async () => { - const visible = await harness(undefined, { canOpenPath: () => true }) - const headless = await harness(undefined, { canOpenPath: () => false }) + const visible = await harness({ canOpenPath: () => true }) + const headless = await harness({ canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) @@ -181,7 +52,7 @@ describe('host.openPath', () => { it('opens through the injected native boundary', async () => { const opened: string[] = [] - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: async (path) => { opened.push(path) }, }) expect((await api.host.openPath( @@ -192,7 +63,7 @@ describe('host.openPath', () => { }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: (_path, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d6be6bf619..45b6d153ce 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -29,9 +29,6 @@ function scriptedApi(overrides: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), - pickDirectory: r => ok(r, { path: null }), - listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), - createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b56fa89d82..9bbd19640b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -18,15 +18,6 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, } }, - async pickDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } - }, - async listDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } - }, - async createDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } - }, async openPath(request) { return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, @@ -124,29 +115,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { .toEqual({ ok: true, value: { opened: true } }) }) - it('round-trips the native picker without the default unary timeout', async () => { - const api = fakeApi() - api.host.pickDirectory = async (request) => { - await new Promise(resolve => setTimeout(resolve, 15)) - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } } - } - const response = await client(api, 1).host.pickDirectory({}) - expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) - }) - - it('round-trips the browse listing and creation calls through the wire form', async () => { - const c = client() - const listed = await c.host.listDirectory({ path: '/w' }) - expect(listed.result).toEqual({ - ok: true, - value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, - }) - const home = await c.host.listDirectory({}) - expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) - const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) - expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) - }) - it('round-trips host.openPath through the wire form', async () => { const api = fakeApi() let opened: string | undefined @@ -165,41 +133,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { - vi.useFakeTimers() - const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { - const controller = new AbortController() - setTimeout(() => { - controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')) - }, milliseconds) - return controller.signal - }) - try { - const api = fakeApi() - api.host.pickDirectory = async (request) => { - await new Promise(resolve => setTimeout(resolve, 30_001)) - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } - } - const execution = client(api).host.pickDirectory({}) - const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { path: '/tmp/slow' } }, - }) - - await Promise.all([ - vi.advanceTimersByTimeAsync(30_001), - assertion, - ]) - expect(timeoutSpy).not.toHaveBeenCalled() - } finally { - timeoutSpy.mockRestore() - vi.useRealTimers() - } - }) - - it('keeps caller and connection aborts on a deadline-exempt unary', async () => { + it('keeps caller and connection aborts on a signal-taking unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -212,7 +149,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).host.pickDirectory({}, controller.signal) + const execution = client(api).host.openPath({ path: '/tmp/a.txt' }, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) @@ -221,9 +158,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into host.pickDirectory', async () => { + it('propagates the carrier Request signal into host.openPath', async () => { const api = fakeApi() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { if (!signal.aborted) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) @@ -236,8 +173,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } const handler = toFetchHandler(api) const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) - const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-opener', method: 'host.openPath', payload: { path: '/tmp/a.txt' } }) + const pending = handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0a803154b8..429e31361f 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -5,11 +5,7 @@ import { rpcResultSchema, serverResponseSchema, } from '../src/api/rpc.schema.ts' import { z } from 'zod' -import { - hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, - hostDescribeRequestSchema, hostDescribeValueSchema, - hostListDirectoryRequestSchema, hostListDirectoryValueSchema, -} from '../src/api/host.schema.ts' +import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts' @@ -36,10 +32,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable') - expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists') - expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed') - expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') @@ -55,7 +47,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -109,26 +100,6 @@ describe('host domain schemas', () => { version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, })).toThrow() }) - - it('validates the browse listing/creation payloads', () => { - expect(hostListDirectoryRequestSchema.parse({})).toEqual({}) - expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' }) - const listing = hostListDirectoryValueSchema.parse({ - path: '/home/u/p', - home: '/home/u', - crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], - entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], - truncated: false, - }) - expect(listing.entries[0]?.hidden).toBe(true) - // The flag is part of the wire value, not an optional decoration. - expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() - expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) - for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { - expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() - } - expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' }) - }) }) describe('skills domain schemas', () => { diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index a101e5580b..a9184c9313 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 0b161afa4b70c291d20523507b62789c4856d29a -README.zh.md: 4207a1eccdb913b322733f9c1e56f79d298c16b7 +README.md: 986504a48da5b1ea28c174168eec41e40bb77e4e +README.zh.md: 54a722bdad2d88e6cadc6f39c74f479851197351 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 0b161afa4b..986504a48d 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -25,7 +25,7 @@ Users who cannot reach an OS chooser still pick a workspace directory through `d ## Use this package -Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `host.listDirectory` and `host.createDirectory`; both primitives answer from the host filesystem. +Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `directoryPicker/list` and `directoryPicker/createDirectory`; both primitives answer from the host filesystem. ### Listing a directory diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4207a1eccd..54a722bdad 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `host.listDirectory` 与 `host.createDirectory`;两个原语都从宿主文件系统作答。 +当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `directoryPicker/list` 与 `directoryPicker/createDirectory`;两个原语都从宿主文件系统作答。 ### 列举目录 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index adbe6d09ad..01fe5b9cd2 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -303,8 +303,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`) } const parent = resolve(path) - // The backend owns segment validation (the wire schema also refuses these, - // but direct service consumers must hit the same fence). + // The backend owns segment validation; the Remote controller also refuses + // invalid wire input, but direct service consumers must hit the same fence. if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`) } diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index ef85adaeeb..aa59d82312 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 9bea790348293398b800271cfee2dce75d02072a -README.zh.md: 53c582c33a55397c1e0fe526e5b92ebc270fe8f7 +README.md: 420ec71899366733c9be538ca5900644e81b668d +README.zh.md: a6671d2f93fbc605bfa74d73e80d835cb0c7824e diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 9bea790348..420ec71899 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -33,7 +33,7 @@ Choose this backend for a workstation-local operator on macOS, Windows, or deskt ### What an operator experiences -Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `host.pickDirectory` and reports the one outcome (picked path, cancel, or failure). +Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `directoryPicker/pick` and reports the one outcome (picked path, cancel, or failure). ### Observable failures diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 53c582c33a..a6671d2f93 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 操作者会看到什么 -每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并上报唯一结果(所选路径、取消或失败)。 +每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `directoryPicker/pick`,并上报唯一结果(所选路径、取消或失败)。 ### 可观察的失败 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 7d6d8ef665..d2d9647738 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md -README.md: 93afde4ec012fe366f0a44e8d9632596876f6b69 -README.zh.md: 1c9942cdc4271b7201d7086868d01da23ccd4d46 +README.md: bcd2c4dbfa2848e669edbc9f0ebba93804df622c +README.zh.md: 8604b54c99e88cedfedf820e3dd0f8800f9c5972 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 93afde4ec0..bcd2c4dbfa 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -29,11 +29,11 @@ Mount exactly one directory-picker backend and let the workspace flow drive it: ### Choosing a backend -The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `host.pickDirectory` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. +The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `directoryPicker/pick` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. ### The capability contract -`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the consuming gateway maps onto wire error codes. +`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the picking Remote controller maps onto wire failure codes. ### What rows carry diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 1c9942cdc4..8604b54c99 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -29,11 +29,11 @@ web GUI 宿主通过一份约定让操作者选择工作区目录:一个只提 ### 选择后端 -当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`host.pickDirectory` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 +当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`directoryPicker/pick` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 ### 能力约定 -`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,消费网关将其 1:1 映射为协议错误码。 +`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,选目录 Remote controller 将其 1:1 映射为协议错误码。 ### 行携带什么 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 5be5940ecd..6fb6652bdc 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e8eb64f87..f4eb0674a2 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -12,6 +12,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import type { DirectoryListing } from './types.ts' + +export type { DirectoryEntry, DirectoryListing } from './types.ts' /** The native interaction: one OS directory chooser on the host display. */ export interface DirectoryPickerNativeCapability { @@ -24,37 +27,6 @@ export interface DirectoryPickerNativeCapability { pick(signal: AbortSignal): Promise } -/** One directory row: a listing child or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — clients never join path segments themselves. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** One directory level plus its ancestry, as a browse backend reports it. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** - * True when the backend cut `entries` at its complete-result bound: the - * level has more child directories than reported, and the missing rows are - * the name-sorted tail (hidden rows count toward the bound). - */ - truncated: boolean -} - /** * The browse interaction: listing/creation primitives an in-app browser * drives one level at a time. Works for remote clients — nothing renders on diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index abf29856fc..5c44aac9af 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -12,7 +12,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this stateless Service Definition owns the capability - * vocabulary, while backends and the RPC consumer own observations. + * vocabulary, while backends and the Remote controller own observations. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/directory-picker/src/types.ts b/packages/host/directory-picker/src/types.ts new file mode 100644 index 0000000000..db1dda57db --- /dev/null +++ b/packages/host/directory-picker/src/types.ts @@ -0,0 +1,39 @@ +/** + * Client-safe type surface of the directory-picking seam: what one browse level + * looks like to a caller. Types only — no runtime code, and nothing here reaches + * a Host-only symbol, so a Client compilation face reads exactly the signatures + * the Host emits. + * + * @module @deepseek-ai/dsh-host-directory-picker/types + */ + +/** One directory row: a listing child or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — clients never join path segments themselves. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** One directory level plus its ancestry, as a browse backend reports it. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcc43f58e1..11f1d8f5d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -838,6 +838,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1612,6 +1615,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2249,9 +2255,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3621,6 +3627,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -3663,6 +3672,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 00359ad450..3d789bb894 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -118,6 +118,7 @@ export const SERVICE_PAGE: Record = { webhookRuntime: 'webhook.md', workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', + directoryPickerController: 'workspace.md', } /** @@ -595,6 +596,7 @@ export const LINK_MAP: Readonly> = { ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', DirectoryPickerCapability: 'workspace.md', + DirectoryListing: 'workspace.md', TypertContribution: 'invariants.md', TypertRemoteEventSource: 'typert.md', TypertFace: 'invariants.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9e0dd0c8bf..22ded5918c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'directoryPickerController', + pkg: 'api-workspace-controller', + title: 'Host directory-picking Remote controller', + mode: 'core', + note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', + }, { key: 'invariants', pkg: 'invariants', @@ -561,7 +568,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Workspace-directory picking seam', mode: 'seam', implementations: ['host-directory-picker-native', 'host-directory-picker-browse'], - consumers: ['host-apiproxy'], + consumers: ['api-workspace-controller'], note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).', }, {