mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #3086 from deepseek-harness/worktree-apire-d2
refactor(apiproxy): directory-picker to Remote
This commit is contained in:
@@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
const exercised = await page.evaluate(async () => {
|
||||
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
|
||||
interface PreviewApi {
|
||||
host: { createDirectory(payload: { path: string; name: string }): Promise<Result<{ path: string }>> }
|
||||
skills: { list(payload: { sessionId: string }): Promise<Result<{ skills: unknown[] }>> }
|
||||
settings: {
|
||||
describe(payload: object): Promise<Result<{ namespaces: Array<{ ns: string; revision: number }> }>>
|
||||
@@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
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 <T>(endpoint: string, args: object): Promise<T> => {
|
||||
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<T>
|
||||
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<void> => {
|
||||
const created = await api.host.createDirectory({ path, name })
|
||||
if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`)
|
||||
await remote<string>('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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@ flowchart LR
|
||||
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
|
||||
pkg_api_workspace_controller["api-workspace-controller"]
|
||||
svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
|
||||
svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
|
||||
svc_invariants["ctx.invariants<br/>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. |
|
||||
|
||||
@@ -42,6 +42,7 @@ flowchart LR
|
||||
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
|
||||
pkg_api_workspace_controller["api-workspace-controller"]
|
||||
svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
|
||||
svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
|
||||
svc_invariants["ctx.invariants<br/>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 扇出。 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-host-directory-picker-browse"></a>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability
|
||||
|
||||
Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts)
|
||||
|
||||
<a id="ctxdirectorypickercontroller--directorypickercontroller"></a>
|
||||
|
||||
### `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<string | null>
|
||||
|
||||
/**
|
||||
* 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<DirectoryListing>
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
```
|
||||
|
||||
Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
|
||||
|
||||
<a id="ctxworkspacecontroller--workspacecontroller"></a>
|
||||
|
||||
### `ctx.workspaceController` — `WorkspaceController`
|
||||
|
||||
@@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability
|
||||
|
||||
Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts)
|
||||
|
||||
<a id="ctxdirectorypickercontroller--directorypickercontroller"></a>
|
||||
|
||||
### `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<string | null>
|
||||
|
||||
/**
|
||||
* 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<DirectoryListing>
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
```
|
||||
|
||||
Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
|
||||
|
||||
<a id="ctxworkspacecontroller--workspacecontroller"></a>
|
||||
|
||||
### `ctx.workspaceController` — `WorkspaceController`
|
||||
|
||||
@@ -76,11 +76,6 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/api/workspace-controller": {
|
||||
"ignoreDependencies": [
|
||||
"zod"
|
||||
]
|
||||
},
|
||||
"packages/client/ui-approval": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.tsx"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
|
||||
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
|
||||
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 目录
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<DirectoryListing> {
|
||||
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<string> {
|
||||
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 extends keyof DirectoryPickerCapabilities>(
|
||||
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 extends keyof DirectoryPickerErrorDetailsMap>(
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<never, never>
|
||||
/** A backend failure with no seam code of its own. */
|
||||
internal: Record<never, never>
|
||||
}
|
||||
|
||||
/** Existing directory requested for Workspace adoption. */
|
||||
export interface WorkspaceCreateRequest {
|
||||
readonly path: string
|
||||
|
||||
@@ -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<unknown>): 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' } })
|
||||
})
|
||||
})
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
export type {
|
||||
ApiProxy, HostApi,
|
||||
DirectoryEntry, DirectoryListing,
|
||||
ResponseValue,
|
||||
SkillsApi, SkillEntry,
|
||||
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
|
||||
@@ -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<string | null> {
|
||||
return { ok: true, value: `${FIXTURE_HOME}/Documents/project` }
|
||||
},
|
||||
list(path?: string): ConnectionRpcResult<FixtureDirectoryListing> {
|
||||
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<string> {
|
||||
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<RpcResponse<unknown>> {
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
truncated: boolean
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => 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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FixtureRemoteEventFrame
|
||||
}
|
||||
|
||||
type FixtureTestApi = ReturnType<typeof createFixtureFaces>['api'] & {
|
||||
/** The directory-picking Remote namespace as the fixture serves it. */
|
||||
readonly directoryPickerRemote: {
|
||||
pick: () => Promise<ConnectionRpcResult<string | null>>
|
||||
list: (path?: string) => Promise<ConnectionRpcResult<DirectoryListing>>
|
||||
createDirectory: (path: string, name: string) => Promise<ConnectionRpcResult<string>>
|
||||
}
|
||||
readonly sessions: FixtureSessionApi
|
||||
readonly sessionRemote: FixtureSessionRemote
|
||||
readonly workspace: FixtureWorkspaceApi
|
||||
@@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType<typeof createFixtureFaces>['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<ConnectionRpcResult<string | null>>,
|
||||
list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as
|
||||
Promise<ConnectionRpcResult<DirectoryListing>>,
|
||||
createDirectory: (path: string, name: string) =>
|
||||
rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as
|
||||
Promise<ConnectionRpcResult<string>>,
|
||||
},
|
||||
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 () => {
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
{
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../host/directory-picker"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<DirectoryListing> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<SlotTestRuntime> {
|
||||
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)
|
||||
|
||||
@@ -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<RemoteResult<string | null>> = () => Promise.resolve({ ok: true, value: null })
|
||||
onList: () => Promise<RemoteResult<DirectoryListing>> = () => Promise.resolve({ ok: true, value: listing })
|
||||
onCreateDirectory: () => Promise<RemoteResult<string>> =
|
||||
() => 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<T>(method: string, payload: unknown, result: Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../connection/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
|
||||
@@ -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<string | null>',
|
||||
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<DirectoryListing>',
|
||||
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<string>',
|
||||
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<DirectoryListing>;\n createDirectory(path: string, name: string): Promise<string>;\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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -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)的范围。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -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<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
||||
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)
|
||||
},
|
||||
|
||||
@@ -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<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
|
||||
|
||||
/** host.pickDirectory response value; null means the user cancelled. */
|
||||
export const hostPickDirectoryValueSchema = z.object({
|
||||
path: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
|
||||
|
||||
/** 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<Wire<DirectoryEntry>>
|
||||
|
||||
/** host.listDirectory request payload; an absent path lists the home directory. */
|
||||
export const hostListDirectoryRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'host.listDirectory'>>>
|
||||
|
||||
/** 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<Wire<ResponseValue<'host.listDirectory'>>>
|
||||
|
||||
/** 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<Wire<RequestPayload<'host.createDirectory'>>>
|
||||
|
||||
/** host.createDirectory response value: the created directory's absolute path. */
|
||||
export const hostCreateDirectoryValueSchema = z.object({
|
||||
path: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.createDirectory'>>>
|
||||
/** host.openPath request payload. */
|
||||
export const hostOpenPathRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
|
||||
@@ -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<RpcResponse<{ path: string | null }>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<DirectoryListing>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<{ path: string }>>
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application
|
||||
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -36,10 +36,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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()) }) }),
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.listDirectory'>>>
|
||||
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
|
||||
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
|
||||
}
|
||||
skills: {
|
||||
@@ -80,9 +76,6 @@ export interface IApiClient {
|
||||
*/
|
||||
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
|
||||
'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<Wire<ResponseV
|
||||
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Whether a unary call uses the transport health deadline or only caller/connection cancellation. */
|
||||
type UnaryTimeoutPolicy = 'default' | 'caller-signal-only'
|
||||
|
||||
/** URL base for in-process handler injection (fake authority, opencode precedent). */
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
|
||||
@@ -122,7 +112,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
private flushScheduled = false
|
||||
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => 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<Response> {
|
||||
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<K>,
|
||||
signal?: AbortSignal,
|
||||
timeoutPolicy: UnaryTimeoutPolicy = 'default',
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(response: { readonly result: { readonly ok: true; readonly
|
||||
}
|
||||
|
||||
async function harness(
|
||||
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
||||
extras: {
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
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 })
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<AbortSignal>()
|
||||
api.host.pickDirectory = async (request, signal) => {
|
||||
api.host.openPath = async (request, signal) => {
|
||||
started.resolve(signal)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<void>((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<void>((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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ Users who cannot reach an OS chooser still pick a workspace directory through `d
|
||||
<a id="use-this-package"></a>
|
||||
## 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
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `host.listDirectory` 与 `host.createDirectory`;两个原语都从宿主文件系统作答。
|
||||
当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `directoryPicker/list` 与 `directoryPicker/createDirectory`;两个原语都从宿主文件系统作答。
|
||||
|
||||
### 列举目录
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
### 操作者会看到什么
|
||||
|
||||
每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并上报唯一结果(所选路径、取消或失败)。
|
||||
每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `directoryPicker/pick`,并上报唯一结果(所选路径、取消或失败)。
|
||||
|
||||
### 可观察的失败
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 映射为协议错误码。
|
||||
|
||||
### 行携带什么
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+14
-2
@@ -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
|
||||
|
||||
@@ -118,6 +118,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
webhookRuntime: 'webhook.md',
|
||||
workspaceRegistry: 'workspace.md',
|
||||
workspaceController: 'workspace.md',
|
||||
directoryPickerController: 'workspace.md',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,6 +596,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ProjectionSnapshot: 'session-projection.md',
|
||||
ProjectionCheckpoint: 'session-projection.md',
|
||||
DirectoryPickerCapability: 'workspace.md',
|
||||
DirectoryListing: 'workspace.md',
|
||||
TypertContribution: 'invariants.md',
|
||||
TypertRemoteEventSource: 'typert.md',
|
||||
TypertFace: 'invariants.md',
|
||||
|
||||
@@ -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).',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user