Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/tasks/tasks-local/src/index.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
#	packages/tasks/tasks/src/index.ts
This commit is contained in:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -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: 5f52781021a70d89c63a49aa74dec002a04d0f40
README.zh.md: 996935b2028c98a97a7d6208c72fc6a72a023ee4
README.md: 2a27ab9f2cfcd4f12c32b815583b13f439b3eaac
README.zh.md: ba49ccff82bab1e677bad1495e48b8503564ae1d

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
## The shared Agent default (`agent-default-model` Settings section)
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference.
`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it.
A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.
@@ -30,15 +30,15 @@ Question responses are validated against their pending request before the first
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale.
`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`.
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable.
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
@@ -52,13 +52,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package.
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
## Model Experience
@@ -70,7 +70,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.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.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
所有客户端共用的 API 网关由三部分组成TypeScript API 约定(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`。该包不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent智能体模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型偏好。
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
@@ -30,15 +30,15 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理reasoning元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中也绝不会作为合成行注入客户端可以提示用户作出另一项选择而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable``session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理reasoning元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中也绝不会作为合成行注入客户端可以提示用户作出另一项选择而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable``session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering中途引导不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement而注入上下文审批通知、任务完成、附加快照携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found``session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
@@ -52,17 +52,17 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`agentPreset.read``copy``openDocument``remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid``remove` 对随附 preset 回答 `agent-preset-read-only``openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径因此没有任何浏览器载荷能选中任意文件系统目标部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答,因此每一种前端web、TUIACPAgent Client Protocol、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和 skill技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt``dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token并以注入的 `<skill_content>` 上下文作答,因此所有入口Web、TUIACPAgent Client Protocol共享同一条确定性路径手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed`注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope不产生任何注册因此该会话组成所决定的两份目录`command.list``skill.list`)都会失效,却没有任何注册表变化来宣告它。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission`产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale``permission``ui-conversation``ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable``credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate``credentials.set`且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据都折叠为 `model-discovery-failed`其消息是适配器自己的文本details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale``permission``ui-conversation``ui-theme``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
## 模型体验
无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。
无。该包定义客户端与宿主间的 wire 约定和载体,其中没有任何内容会进入模型请求。
#### KV Cache 影响
@@ -70,7 +70,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
## 已知限制与暂缓事项
- **待处理交互状态位于宿主侧**协议形状为 POST `/api/respond``RpcReceipt``src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
- **待处理交互状态位于宿主侧**wire 使用 POST `/api/respond``RpcReceipt``src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-apiproxy",
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/apiproxy"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -38,6 +45,7 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
@@ -62,13 +70,13 @@
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"schemastery": "^3.18.0",
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent-presets": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent-presets": "workspace:^",
@@ -77,6 +85,6 @@
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -5,14 +5,16 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import { dirname } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
@@ -31,11 +33,12 @@ import {
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
@@ -94,7 +97,7 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-
const DEFAULT_MAX_MESSAGES = 50
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
@@ -105,6 +108,113 @@ const COLD_SUMMARY_BATCH_SIZE = 16
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
/** Decode the browser payload while rejecting non-canonical base64 forms. */
function decodeBase64(data: string): Uint8Array {
const decoded = Buffer.from(data, 'base64')
if (data.length === 0 || decoded.toString('base64') !== data) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
return new Uint8Array(decoded)
}
/** Validate one prompt as a batch before publishing any durable image object. */
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const limits = ctx.attachments.imageLimits
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const prepared = content.map(part => part.type === 'text'
? part
: { part, data: decodeBase64(part.data) })
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
if (totalBytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
for (const image of images) {
await ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
}
const blocks: ContentBlock[] = []
for (const item of prepared) {
if (!('data' in item)) {
blocks.push({ type: 'text', text: item.text })
continue
}
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
blocks.push({ type: 'image', attachment })
}
return blocks
}
/** Search durable content for an image reference, including nested tool results. */
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
if (!Array.isArray(content)) return undefined
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (match(ref)) return ref
}
if (block.type === 'tool-result') {
const nested = imageBlockIn(block.content, match)
if (nested !== undefined) return nested
}
}
return undefined
}
/** Search every durable event carrier that can own model-visible content. */
function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
const data = event.data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
chunk?: { type?: unknown; block?: unknown }
}
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
if (data.message !== undefined) {
const wrapped = imageBlockIn(data.message.content, match)
if (wrapped !== undefined) return wrapped
}
if (data.inserted !== undefined) {
for (const message of data.inserted) {
const inserted = imageBlockIn(message.content, match)
if (inserted !== undefined) return inserted
}
}
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
return imageBlockIn([data.chunk.block], match)
}
return undefined
}
/** True when the current model-visible surface contains an image. */
function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
return messages.some(message => contentHasImage(message.content))
}
/** Resolve the first reference matching one opaque id. */
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
if (found !== undefined) return found
}
return undefined
}
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
@@ -422,8 +532,6 @@ export interface ApiProxyDefaults {
saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
@@ -814,9 +922,6 @@ class SessionCwdConflict extends Error {
}
}
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** An explicit Host naming operation would duplicate another Workspace title. */
class WorkspaceNameConflictError extends Error {
constructor(readonly workspaceName: string) {
@@ -888,6 +993,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** Serialize image admission with model selection for one agent. */
function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
return result
}
/**
* Install or return the session-local model selection that prompt assembly snapshots.
@@ -944,7 +1057,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* common paths — reconnecting, resuming, retrying a create — are unaffected.
* @param sessionId - the identity being adopted.
* @param requested - the preset the request named, if any.
* @param existing - the preset the session was created under, if any.
* @param existing - the preset the session RUNS, if any; both callers resolve
* it from the log, which differs from the creation header once a blank
* session has switched.
* @throws when both are present and differ.
*/
function assertPresetUnchanged(
@@ -1257,17 +1372,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* The registry view scope a transcript's presenters resolve in.
*
* A live agent is that scope itself (its chain passes through its preset's
* standing layer). A cold session names its preset on the header, and the
* standing layer). A cold session resolves its preset from the LOG, and the
* preset's STANDING key serves without resuming anything — ensuring the
* mount composes plugins but starts no agent, session, or turn. No roster,
* no recorded preset, or a preset the roster no longer supplies all fall
* back to the global layer: the transcript still serves, with the generic
* cards a viewless entry renders.
*
* Reading the header alone would render a session that switched while blank
* through the composition it was CREATED with. Every tool only the newer
* preset registers resolves to no presenter there, and the transcript
* silently degrades to generic cards for exactly the calls its history is
* made of.
* @param sessionId - the transcript being read.
* @param header - that session's header (attached or inspected).
* @param session - that session's header and log (attached or inspected).
* @returns the scope to pass to presenter lookups, or undefined for global.
*/
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
async function presenterScopeFor(
sessionId: SessionId,
session: PresetBearingSession,
): Promise<ScopeKey | undefined> {
const live = ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = ctx.get('agentPresets')
@@ -1277,7 +1401,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// through the DEFAULT preset's standing layer: that is the composition
// an unnamed session composes today, and presenters are pure display,
// so the worst a mismatch produces is the generic card it had anyway.
return await presets.standingKeyFor(header.agentPreset)
return await presets.standingKeyFor(resolveSessionPreset(session))
} catch {
// Swallows only the unknown/unusable-preset rejection from the roster:
// a deleted or broken preset must degrade this read, never fail it.
@@ -1370,7 +1494,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Beside the cwd check for the same reason, and after the await so it
// covers every path that yields a live agent — freshly created, adopted
// live, resumed from disk, or recovered by the concurrent-creation catch.
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -1378,29 +1502,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace(
path: string,
title: string | undefined,
rejectExistingName = false,
createDirectory = false,
): Promise<{ workspace: Workspace; created: boolean }> {
function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => {
if (rejectExistingName && title !== undefined
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
throw new WorkspaceNameConflictError(title)
}
if (createDirectory) {
try {
await mkdir(path, { recursive: true })
} catch (error: unknown) {
throw new WorkspaceDirectoryCreationError(
`failed to create workspace directory "${path}": ${String(error)}`,
)
}
}
const existing = await ctx.workspace.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path, title), created: true }
return { workspace: await ctx.workspace.create(path), created: true }
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation
@@ -1904,12 +2010,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
// Echo the RESOLVED composition so a client can label the session it
// just created without waiting for the next list refresh — the create
// is the commit point that knows it (a caller that named none gets
// the default the header recorded).
// Echo the composition the session RUNS so a client can label it
// without waiting for the next list refresh — the create is the commit
// point that knows it (a caller that named none gets the default).
// Resolved from the log for the same reason `sessionListFields()` is:
// this handler also adopts an already-live session, and one that
// switched while blank runs a preset its header no longer names, so
// echoing the header would contradict both the adoption this call just
// allowed and the row `session.list` serves for the same session.
const created = ctx.agents.get(sessionId)
const createdPreset = created?.session.header.agentPreset
const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
},
@@ -1928,7 +2038,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
return ok(request, {
events: page.events,
hasMore: page.hasMore,
@@ -1950,41 +2060,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId, provider, model, reasoningEffort } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
selectionFor(found.agent).current = selected
// A switch is also how this deployment's default is chosen: the next
// session created without one of its own starts here. Sessions that
// have already logged a selection are unaffected — they derive from
// their own log (see selectionFor).
return serializeImageAdmission(found.agent, async () => {
try {
await defaults.saveDefaultModelSelection?.(selected)
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep]
.some(message => contentHasImage(message.content))
if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) {
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
return err(request, {
code: 'model-unavailable',
message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`,
details: { provider, model },
})
}
}
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
selectionFor(found.agent).current = selected
try {
await defaults.saveDefaultModelSelection?.(selected)
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
)
}
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
)
return err(request, {
code: 'model-unavailable',
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
code: 'model-unavailable',
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
})
},
async rename(request) {
@@ -2122,19 +2242,101 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const agent = resolved.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
const message: UserMessage = createUserMessage({ content, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
const hasImage = content.some(part => part.type === 'image')
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
try {
if (hasImage) {
const current = selectionFor(agent).current
const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
return err(request, {
code: 'attachment-error',
message: `Model "${current.model}" does not support image input.`,
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
})
}
}
const durable = await durablePromptContent(ctx, content)
const message: UserMessage = createUserMessage({ content: durable, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, {
code: 'agent-busy',
message: 'prompt rejected',
details: { reason: String(error) },
})
}
return ok(request, { accepted: true as const })
}
return hasImage ? serializeImageAdmission(agent, admit) : admit()
},
async attachment(request) {
const { sessionId, attachmentId } = request.payload
let state: SessionReadState
try {
state = await readSessionState(sessionId)
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return err(request, {
code: 'session-not-found',
message: error.message,
details: { sessionId },
})
}
return err(request, {
code: 'internal',
message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`,
details: {},
})
}
const ref = referencedImage(state.events, String(attachmentId))
if (ref === undefined) {
return err(request, {
code: 'attachment-error',
message: 'Image is not referenced by this session.',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
try {
const stored = await ctx.attachments.readImage(ref)
return ok(request, {
attachment: stored.ref,
data: Buffer.from(stored.data).toString('base64'),
})
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, {
code: 'internal',
message: 'Unable to read image attachment.',
details: {},
})
}
return ok(request, { accepted: true as const })
},
updateQueue(request) {
const { sessionId, itemId, action } = request.payload
if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
return Promise.resolve(err(request, {
code: 'attachment-error',
message: 'queue edits accept text content only',
details: { reason: 'QUEUE_EDIT_NON_TEXT' },
}))
}
const agent = ctx.agents.get(sessionId)
if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
@@ -2354,54 +2556,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}))
},
// Exactly one of path/name arrives (schema refine). Existing-folder
// adoption reuses its canonical path; create-by-name rejects a name
// already present in the registry.
// TODO: the create-by-name branch lost its last product consumer when
// the Web picker collapsed onto the directory flow
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
// Delete it with the wire schema's `name` member, this
// `defaults.workspaceRoot`, the client contract that carried the name
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
// `intentName`'s name branch, the manager's "name under workspaceRoot"
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
// README lines, which exist only to feed it.
async create(request) {
const { payload } = request
let path: string
if (payload.name !== undefined) {
const name = payload.name.trim()
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
return err(request, {
code: 'workspace-invalid-path',
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
details: { path: payload.name },
})
}
path = join(defaults.workspaceRoot, name)
} else {
path = payload.path as string
}
const { path } = request.payload
try {
const name = payload.name?.trim()
const { workspace, created } = await ensureWorkspace(
path,
name,
name !== undefined,
name !== undefined,
)
const { workspace, created } = await ensureWorkspace(path)
return ok(request, { workspace: workspaceView(workspace), created })
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
if (error instanceof WorkspaceDirectoryCreationError) {
return err(request, { code: 'internal', message: error.message, details: {} })
}
// The registry rejects a path that does not resolve to an existing
// directory (realpath ENOENT / not-a-directory) — the business
// error of the typed-path flow, surfaced as a validation failure.
@@ -2857,7 +3017,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// The scope presenters resolve in — the live agent, else the recorded
// preset's standing key, else the global layer — so a cold session's
// '/' popup lists the catalog its composition actually serves.
const scope = await presenterScopeFor(sessionId, session.header)
const scope = await presenterScopeFor(sessionId, session)
try {
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
return ok(request, {
@@ -3222,6 +3382,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.on('commands/change', () => {
queue.push(frame({ type: 'host/commands-changed' }))
}),
// The recompose itself registers nothing (it re-parents the agent's
// scope onto a standing mount that may already exist), so the
// logged selection is the only commit point a client can follow.
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'agent-preset/selected') return
queue.push(frame({
type: 'host/session-preset-changed',
sessionId: session.id,
agentPreset: event.data.agentPreset,
}))
}),
ctx.on('settings/document-updated', (ns) => {
// The RAW-section event, not the resolved one: a field going from
// inherited to overridden leaves the resolved value equal, and a

View File

@@ -10,7 +10,7 @@ import type { ApprovalResponsePayload } from './approvals.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */
/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */
export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType<ApprovalRequestId>
/** Approval answer payload (the result.value slot of a client-response). */

View File

@@ -33,7 +33,7 @@ export const commandExecuteRequestSchema = z.object({
line: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
/** CommandId: one brand cast after schema validation (the only cast point in this domain). */
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
/** command.execute response value: pure admission — outcomes ride the logged

View File

@@ -16,7 +16,7 @@ import {
import { taskViewSchema } from './tasks.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
/** Question fields validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
id: z.string(),
question: z.string(),
@@ -84,6 +84,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
z.object({ type: z.literal('host/models-changed') }),

View File

@@ -145,6 +145,18 @@ export type HostFrame =
* background rather than diffing.
*/
| { type: 'host/commands-changed' }
/**
* One blank session was recomposed onto another agent preset (the logged
* `agent-preset/selected` commit point, read off the session stream). The
* registry-wide `host/commands-changed` cannot stand in for it: recomposing
* re-parents that agent's scope without registering anything, so a
* preset already mounted for another session produces no registry change
* at all. Clients refetch the catalogs this session's composition decides
* (`command.list`, `skill.list`) for this sessionId alone, and fold the
* preset id into their session row — the RPC echo reaches only the client
* that issued the switch, so the row is where every other one learns it.
*/
| { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
/**
* One settings namespace's resolved value changed (`settings/updated`
* passthrough) — an RPC write, an external `settings.yaml` edit, or a

View File

@@ -17,8 +17,6 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
// Open string, not a literal union: unknown kinds must survive the wire so
// a merge-added capability can advertise (the client hides the affordance).
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */

View File

@@ -39,7 +39,8 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'

View File

@@ -32,6 +32,7 @@ export interface RpcMethodMap {
'session.rename': SessionsApi['rename']
'session.fork': SessionsApi['fork']
'session.prompt': SessionsApi['prompt']
'session.attachment': SessionsApi['attachment']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']
'subagent.list': SubagentsApi['list']

View File

@@ -23,8 +23,8 @@ export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
: T
/**
* RpcId: one brand cast after shape validation (the only cast point in this
* file). No min-length: the id is an opaque echo token, and rejecting shapes
* RpcId: one brand cast after schema validation (the only cast point in this
* file). No min-length: the id is an opaque echo token, and rejecting values
* here would only turn a correlatable error report into a client-side parse
* failure (the handler substitutes a sentinel when a request's id is unreadable).
*/
@@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),

View File

@@ -50,6 +50,7 @@ export interface RpcErrorDetailsMap {
'agent-preset-not-found': { agentPreset: string; available: string[] }
'agent-preset-invalid': { agentPreset: string; reason: string }
'agent-busy': { reason: string }
'attachment-error': { reason: string }
'queue-item-not-found': { itemId: MessageId }
'steer-unavailable': { itemId: MessageId }
/** A known slash command reported a usage/state error; the message is the command's own text. */

View File

@@ -15,6 +15,7 @@ import type {
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { WorkspaceId } from './workspace.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -22,7 +23,7 @@ import {
truncateUnicodeCodePoints,
} from './session-search.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
/** SessionId: one brand cast after schema validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
/** MessageId: one brand cast after non-empty string validation. */
@@ -44,6 +45,7 @@ export const sessionEventSchema = z.object({
data: z.unknown(),
sourceEventSeqs: z.array(z.number()).optional(),
surfaceOp: z.unknown().optional(),
ignorable: z.literal(true).optional(),
}) as unknown as z.ZodType<SessionEvent>
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
@@ -249,11 +251,25 @@ export const sessionSelectModelValueSchema = z.object({
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })
/** Raster image media types accepted by the version-one browser wire. */
export const imageMediaTypeSchema = z.union([
z.literal('image/png'),
z.literal('image/jpeg'),
z.literal('image/webp'),
z.literal('image/gif'),
])
/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */
export const promptContentPartSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('text'), text: z.string() }),
z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }),
])
/** session.prompt request payload. */
export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),
content: z.array(contentBlockSchema),
content: z.array(promptContentPartSchema),
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
@@ -265,6 +281,31 @@ export const sessionPromptValueSchema = z.object({
}).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** Opaque attachment id after string-shape validation. */
export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType<AttachmentIdType>
/** Durable image reference returned from the authenticated session lookup. */
export const imageAttachmentRefSchema = z.object({
attachmentId: attachmentIdSchema,
mediaType: imageMediaTypeSchema,
bytes: z.number().int().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
name: z.string().optional(),
}) as unknown as z.ZodType<ImageAttachmentRef>
/** session.attachment request payload. */
export const sessionAttachmentRequestSchema = z.object({
sessionId: sessionIdSchema,
attachmentId: attachmentIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.attachment'>>>
/** session.attachment response value. */
export const sessionAttachmentValueSchema = z.object({
attachment: imageAttachmentRefSchema,
data: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.attachment'>>>
/** session.updateQueue request payload. */
export const sessionUpdateQueueRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -5,6 +5,7 @@
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// The pure-type outlet: api/ is browser-importable, and the package root's
@@ -53,6 +54,11 @@ export interface SessionProjectionsBlock {
values: Partial<SessionProjectionMap>
}
/** Browser-submitted prompt content; the host promotes image bytes to durable references. */
export type PromptContentPart =
| { type: 'text'; text: string }
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
/** Complete model selection for one session. */
export interface ModelSelection {
/** Registered provider route. */
@@ -302,10 +308,14 @@ export interface SessionsApi {
fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
/** Sends text and temporary image bytes after durable host admission. Session-backed subagents reject with `agent-busy`. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/** Reads one durable image after proving that this session's log references its id. */
attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>):
Promise<RpcResponse<{ attachment: ImageAttachmentRef; data: string }>>
/**
* Edits, removes, or strictly steers one pending queued occurrence on an ordinary session.
* Session-backed subagents reject with `agent-busy`.

View File

@@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({
archivedSessionIds: z.array(sessionIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
/** workspace.create request payload: the existing directory to adopt. */
export const workspaceCreateRequestSchema = z.object({
path: z.string().optional(),
name: z.string().optional(),
}).refine(
payload => (payload.path === undefined) !== (payload.name === undefined),
{ message: 'workspace.create requires exactly one of path / name' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
path: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
/** workspace.create response value. */
export const workspaceCreateValueSchema = z.object({

View File

@@ -46,19 +46,14 @@ export interface WorkspaceApi {
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
* `name` (schema-enforced): `path` registers an EXISTING directory (no
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
* `name` is a single path segment the host mkdirs under its default project
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; path adoption allows distinct canonical paths
* whose basenames produce the same display title.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
* Creates (or idempotently resolves) a workspace over an EXISTING directory
* (no mkdir — a missing or non-directory path fails with
* `workspace-invalid-path`). A path resolving to a directory already owned
* by a workspace returns that workspace (`created: false`). Adoption allows
* distinct canonical paths whose basenames produce the same display title;
* the registry's basename title default names the new workspace.
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
create(request: RpcRequest<{ path: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
/**

View File

@@ -19,6 +19,7 @@ import {
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionAttachmentValueSchema,
sessionCreateValueSchema,
sessionForkValueSchema,
sessionHistoryValueSchema,
@@ -94,6 +95,7 @@ export interface IApiClient {
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
@@ -180,6 +182,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.rename': sessionRenameValueSchema,
'session.fork': sessionForkValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.attachment': sessionAttachmentValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'subagent.list': subagentListValueSchema,
@@ -420,6 +423,7 @@ export abstract class AbstractApiClient implements IApiClient {
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts'
import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
import {
sessionCancelRequestSchema,
sessionAttachmentRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionHistoryRequestSchema,
@@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },

View File

@@ -8,13 +8,12 @@
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
* shared with direct front doors. Switching models persists through that
* shared with direct entry points. Switching models persists through that
* service; sessions that have already logged a selection remain unchanged.
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
@@ -27,17 +26,15 @@ export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
apiProxy: ApiProxy
}
}
/** Gateway plugin config: the Host-only Workspace creation root. */
/** Gateway plugin config for native Host integration. */
export interface Config {
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
@@ -51,16 +48,15 @@ export interface Config {
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
* project directory and the fallback parent for name-created Workspaces.
* project directory.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
@@ -80,12 +76,10 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
const api = createApiProxy(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
cwd: process.cwd(),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy'

View File

@@ -8,12 +8,13 @@
import { mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import type { HostFrame } from '../src/api/events.ts'
import {
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
@@ -136,7 +137,6 @@ async function harness(
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
workspaceRoot: cwd,
...options.defaults,
})
return { api, ctx, cwd }
@@ -186,6 +186,29 @@ describe('session.create with an agent preset', () => {
})
})
it('adopts a live session under the preset it SWITCHED to', async () => {
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
// Exactly what `agentPreset.select` leaves behind on a blank session: the
// header keeps the creation fact, the log states what the agent runs.
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }))
const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
// Comparing against the header would invert both answers: the preset the
// session actually runs would be refused, and the one it left would pass.
expect(adopted.result.ok).toBe(true)
// The echo has to name the same preset the adoption just accepted, or the
// client labels the session with one it has already left — and disagrees
// with the row `session.list` serves for it.
if (!adopted.result.ok) throw new Error('unreachable')
expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
expect(stale.result.ok).toBe(false)
if (stale.result.ok) throw new Error('unreachable')
expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })
})
it('adopts a live session unchanged when the caller names no preset', async () => {
const { api } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
@@ -331,11 +354,11 @@ describe('agentPreset.select', () => {
})
it('records the switch in the log, and the list reads it back', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
await api.agentPresets.select(
request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' }))
request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
// The header is written once at creation, so the switch lives in the log —
// this is what a restart replays and what every projection resolves from.
@@ -343,22 +366,53 @@ describe('agentPreset.select', () => {
const session = ctx.sessions.get(SessionId('sel-log'))
if (session === undefined) throw new Error('unreachable')
expect(session.header.agentPreset).toBe('standard')
expect(resolveSessionPreset(session)).toBe('core-web')
expect(resolveSessionPreset(session)).toBe('minimal')
const listed = await api.sessions.list(request({}))
if (!listed.result.ok) throw new Error('unreachable')
expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
.toBe('core-web')
.toBe('minimal')
})
it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
// The host-stream opener reads the committed-workspace baseline; this
// spec owns preset identity, so the stub suffices (api-proxy-commands
// precedent).
ctx.provide('workspace', { list: () => [] } as never)
const abort = new AbortController()
const frames: HostFrame[] = []
const stream = api.events.host(request({}), abort.signal)
const consume = (async () => {
for await (const frame of stream) {
if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
}
})()
await api.agentPresets.select(
request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
// The queue push rides the synchronous append, so one turn of the loop is
// enough to deliver it; closing the stream bounds the read either way.
await new Promise(resolve => setTimeout(resolve, 0))
abort.abort()
await consume
// Recomposing registers nothing, so this frame — not the registry-wide
// commands one — is what tells a client its cached catalogs are stale.
expect(frames).toEqual([
{ type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
])
})
it('serializes two concurrent selects on one session', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
// Both pass the blank check; unserialized, the second unmount finds no
// record because the first already removed it, and two compositions end up
// in one agent layer. The client's busy flag is not enforcement.
const [first, second] = await Promise.all([
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })),
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
])
@@ -581,7 +635,7 @@ describe('skills over the layered host registry', () => {
})
it('resolves a cold session to its recorded preset standing key', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const { api, ctx } = await harness(['standard', 'minimal'])
const seen: unknown[] = []
ctx.provide('skills', {
list: (options: { scope?: unknown }) => {
@@ -589,12 +643,12 @@ describe('skills over the layered host registry', () => {
return Promise.resolve([])
},
} as never)
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
expect(seen).toEqual([standingKeys.get('core-web')])
expect(seen).toEqual([standingKeys.get('minimal')])
})
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
@@ -617,8 +671,8 @@ describe('skills over the layered host registry', () => {
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' }))
const { api } = await harness(['standard', 'minimal'])
await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' }))
// Cold: creation registered a live agent in this harness, so simulate the
// cold path by asking for a session only persistence knows... the harness
// has no persistence, so read the live one and assert no roster query.
@@ -629,6 +683,27 @@ describe('session.history presenter scope', () => {
expect(standingKeyRequests).toEqual([])
})
it('resolves a switched session from the LOG, not its creation header', async () => {
// The header is a creation fact; a switch while blank is a logged event,
// and every turn after it ran under the newer composition. Reading the
// header would render that history through the older preset's layer,
// where the tools it is made of have no presenter at all.
const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' }
const { api } = await harness(['standard', 'minimal'], {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({
meta,
events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }],
}),
})
standingKeyRequests.length = 0
const response = await api.sessions.history(request({ sessionId: SessionId('p4') }))
expect(response.result.ok).toBe(true)
expect(standingKeyRequests).toEqual(['minimal'])
})
it('serves a COLD transcript whose standing mount is no longer usable', async () => {
// A genuinely cold session: persistence knows it, no live agent exists.
const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }

View File

@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
return { ctx, api }
}
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()

View File

@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry)
return {
ctx,
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},

View File

@@ -8,7 +8,7 @@ import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
return undefined
},
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
@@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -150,7 +150,7 @@ describe('cold history recovery view', () => {
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed')
@@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => {
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => {
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -312,7 +312,7 @@ describe('subagent ownership fence', () => {
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
@@ -371,7 +371,7 @@ describe('subagent ownership fence', () => {
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const prompt = await api.sessions.prompt(request({
sessionId,
@@ -412,7 +412,7 @@ describe('subagent ownership fence', () => {
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
@@ -458,7 +458,7 @@ describe('subagent ownership fence', () => {
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
@@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
@@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
list: () => Promise.resolve([]),
inspect,
} as never)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
@@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await api.sessions.prompt(request({
@@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)

View File

@@ -10,7 +10,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

View File

@@ -6,8 +6,8 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -25,7 +25,7 @@ import { RpcId } from '../src/api/rpc.ts'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
@@ -309,8 +309,8 @@ describe('settings domain', () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a
// future internal plugin cannot become remotely configurable just by
// registering; permission and the product onboarding namespace are the
// non-model namespaces intentionally admitted by this surface.
// registering; locale, permission, conversation, theme, and the product
// onboarding namespace are intentionally admitted by this surface.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)
ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() }))
@@ -319,15 +319,41 @@ describe('settings domain', () => {
}), {
base: { defaultPreset: 'read-only' },
})
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
ctx.settings.register(settingsNamespace('locale'), z.object({
preference: z.union(['zh', 'en']).required(false),
}))
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
busyEnter: z.union(['queue', 'steer']).default('queue'),
}))
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission'])
expect(value.namespaces.map(view => view.ns)).toEqual([
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }],
})))
expect(permission.value).toEqual({ defaultPreset: 'workspace-write' })
const theme = expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
expect(theme.value).toEqual({ preference: 'dark' })
const locale = expectOk(await api.settings.mutate(request({
ns: 'locale',
ops: [{ op: 'set', path: ['preference'], value: 'en' }],
})))
expect(locale.value).toEqual({ preference: 'en' })
const conversation = expectOk(await api.settings.mutate(request({
ns: 'ui-conversation',
ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }],
})))
expect(conversation.value).toEqual({ busyEnter: 'steer' })
for (const response of [
await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })),
@@ -341,19 +367,29 @@ describe('settings domain', () => {
expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({})
})
it('serves the product onboarding namespace without invalidating the model catalog', async () => {
it('serves product preference namespaces without invalidating the model catalog', async () => {
const ctx = await harness()
ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() }))
ctx.settings.register(settingsNamespace('ui-theme'), z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}))
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.toEqual(['ui-onboarding'])
const frames = await collectHost(api, ['host/settings-changed'], 1, async () => {
.toEqual(['ui-onboarding', 'ui-theme'])
const frames = await collectHost(api, ['host/settings-changed'], 2, async () => {
expectOk(await api.settings.mutate(request({
ns: 'ui-onboarding',
ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }],
})))
expectOk(await api.settings.mutate(request({
ns: 'ui-theme',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
})))
})
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }])
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'ui-onboarding' },
{ type: 'host/settings-changed', ns: 'ui-theme' },
])
})
it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => {

View File

@@ -1,7 +1,7 @@
/** Session-fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
@@ -84,7 +84,6 @@ function liveAgent(
const api = (ctx: Context) => createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
describe('sessions.fork', () => {

View File

@@ -5,14 +5,15 @@
* boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -108,7 +109,8 @@ async function harness(logged?: {
session,
status: 'running',
ctx,
} as Agent
inbox: { nextTurn: [], nextStep: [] },
} as unknown as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
@@ -118,14 +120,161 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
return response.result.value
}
function registerTextOnly(ctx: Context): void {
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
}('Text Only', []))
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
}))
ctx.provide('attachments', {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
mediaTypes: ['image/png'],
},
validateImage,
saveImage,
} as never)
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const result = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
{ type: 'text' as const, text: 'compare' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
],
}))
expect(result.result.ok).toBe(true)
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{
type: 'image',
attachment: {
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
},
},
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
])
const denied = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: Array.from({ length: 3 }, () => ({
type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
})),
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
})
expect(saveImage).toHaveBeenCalledTimes(2)
await ctx.fiber.dispose()
})
it('refuses a text-only selection while durable or pending image content remains visible', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const image = {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
agent.session.append('user/message', {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
} as never)
expect((await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).result.ok).toBe(false)
;(agent.inbox.nextTurn as UserMessage[]).length = 0
expect(expectValue(await api.sessions.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
}
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: 'queued-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: ref }],
}],
} as never)
const allowed = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-authorized' as never,
}))
expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
const denied = await api.sessions.attachment(request({
sessionId, attachmentId: 'att-other' as never,
}))
expect(denied.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -160,7 +309,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -232,7 +381,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
@@ -257,7 +405,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
@@ -277,7 +424,6 @@ describe('Web session model selection', () => {
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
workspaceRoot: '/tmp',
})
expectValue(await api.sessions.selectModel(request({
@@ -308,7 +454,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
// The client disabling its input is an affordance; this method stays
@@ -341,7 +486,6 @@ describe('Web session model selection', () => {
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const catalog = expectValue(await api.sessions.models(request({ sessionId })))

View File

@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
}
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
}
}

View File

@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

View File

@@ -5,7 +5,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { stat } from 'node:fs/promises'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { RpcId } from '../src/api/rpc.ts'
@@ -95,7 +95,7 @@ function bench(options: {
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}

View File

@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -65,7 +65,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
return { ctx, session, agent }
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
async function collect(

View File

@@ -8,7 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)

View File

@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
@@ -101,11 +101,17 @@ async function harness(
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: workspaceRoot,
workspaceRoot,
cwd: root,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
return { api, ctx, storageDomain, root }
}
/** Stage one directory under the harness root for path adoption. */
function stageDir(root: string, name: string): string {
const path = join(root, name)
mkdirSync(path)
return path
}
describe('host.pickDirectory', () => {
@@ -243,31 +249,25 @@ describe('host.openPath', () => {
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()
it('serializes concurrent creates of one path into a single registration', async () => {
const { api, root } = await harness()
const target = stageDir(root, 'alpha')
const responses = await Promise.all([
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ name: 'alpha' })),
api.workspace.create(request({ path: target })),
api.workspace.create(request({ path: target })),
])
const created = responses.find(response => response.result.ok)
const duplicate = responses.find(response => !response.result.ok)
const values = responses.map(response => expectOk(response))
const created = values.find(value => value.created)
const resolved = values.find(value => !value.created)
expect(created).toBeDefined()
expect(expectOk(created!)).toMatchObject({
created: true,
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
})
expect(duplicate?.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
})
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
})
it('adopts only existing directories and rejects unsafe names', async () => {
const { api, workspaceRoot } = await harness()
const existing = join(workspaceRoot, 'existing')
mkdirSync(existing)
it('adopts only existing directories', async () => {
const { api, root } = await harness()
const existing = stageDir(root, 'existing')
const first = expectOk(await api.workspace.create(request({ path: existing })))
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
@@ -280,21 +280,16 @@ describe('workspace.create', () => {
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
expect(reopened.workspace.title).toBe('renamed-existing')
const missing = join(workspaceRoot, 'missing')
const missing = join(root, 'missing')
const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
expect(existsSync(missing)).toBe(false)
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
const invalid = await api.workspace.create(request({ name }))
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
})
it('adopts different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
const { api, root } = await harness()
const first = join(root, 'one', 'project')
const second = join(root, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
@@ -315,8 +310,8 @@ describe('workspace.create', () => {
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const sessionId = SessionId('session-workspace-preallocated')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => {
})
it('retains a published session when attachment fails and repairs it on retry', async () => {
const { api, ctx } = await harness()
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const { api, ctx, root } = await harness()
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const workspace = ctx.workspace.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
@@ -393,7 +388,7 @@ describe('Host Workspace increments', () => {
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
const { api, root } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
@@ -401,7 +396,7 @@ describe('Host Workspace increments', () => {
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream)
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
expect(await workspaceIncrement).toMatchObject({
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
})
@@ -429,7 +424,7 @@ describe('Host Workspace increments', () => {
})
it('does not publish a Workspace whose registry-order commit fails', async () => {
const { api, storageDomain } = await harness()
const { api, storageDomain, root } = await harness()
const domain = storageDomain.get('workspace')
if (domain === undefined) throw new Error('workspace domain is not open')
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
@@ -438,7 +433,7 @@ describe('Host Workspace increments', () => {
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next()
const failed = await api.workspace.create(request({ name: 'ghost' }))
const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
expect(failed.result.ok).toBe(false)
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
abort.abort()
@@ -446,8 +441,8 @@ describe('Host Workspace increments', () => {
})
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
const { api, ctx } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
const sessionId = SessionId('session-kept-after-workspace-delete')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -479,8 +474,8 @@ describe('Host Workspace increments', () => {
})
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
const { api } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
const { api, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])

View File

@@ -56,6 +56,10 @@ function scriptedApi(overrides: {
rename: r => ok(r, { title: 'renamed', seq: 0 }),
fork: r => ok(r, { sessionId: sid('s-fork') }),
prompt: r => ok(r, { accepted: true as const }),
attachment: r => ok(r, {
attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: 'AA==',
}),
updateQueue: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
@@ -428,8 +432,8 @@ describe('workspace domain round trip', () => {
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
const response = await client(scriptedApi()).workspace.create({})
it('rejects a pathless create payload at the handler schema', async () => {
const response = await client(scriptedApi()).workspace.create({} as never)
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
})

View File

@@ -97,6 +97,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
async attachment(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } },
}
},
async updateQueue(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -357,6 +363,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,

View File

@@ -330,11 +330,11 @@ describe('workspace domain schemas', () => {
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
it('create requires exactly one of path/name (both refine arms)', () => {
it('create requires a path', () => {
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
// The retired create-by-name spelling stays a clean schema rejection.
expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow()
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
})
@@ -506,6 +506,7 @@ describe('events frame schemas', () => {
} },
{ type: 'host/workspace-removed', workspaceId: 'w' },
{ type: 'host/commands-changed' },
{ type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })

View File

@@ -29,6 +29,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../llm/llm"
},

View File

@@ -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-auto/README.md
README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6
README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944
README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b
README.zh.md: dc67456e9b86636522406bf6a57929b24793dade

View File

@@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.

View File

@@ -16,6 +16,6 @@
## 已知限制与暂缓事项
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记Aqua 会话之外的 darwin 进程仍被算作有显示;`ssh -L` 形态(在工作站本地启动、之后经转发端口访问,`127.0.0.1` 到达会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse`固定住安全的交互。
- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会`127.0.0.1` 到达,系统会判定 `native`把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse`选择安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenitykdialogshell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-auto",
"description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/directory-picker-auto"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,21 +32,21 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1",
"@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -10,9 +10,9 @@
* @module @deepseek-ai/dsh-host-directory-picker-auto
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
import type {} from '@cordisjs/plugin-loader'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
import type { DirectoryPickerBackendKind } from './resolve.ts'

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-host-directory-picker-auto/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto'

View File

@@ -13,9 +13,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import HttpServer from '@deepseek-ai/dsh-host-webserver'
import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-browse",
"description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/directory-picker-browse"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -32,16 +39,16 @@
"dependencies": {
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"clsx": "^2.0.0",
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -53,15 +60,17 @@
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
}
}

View File

@@ -12,8 +12,8 @@
import { mkdir, opendir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-host-directory-picker-browse/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse'

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { Context } from 'cordis'
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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-native",
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/directory-picker-native"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -40,11 +47,11 @@
"koffi": "^3.1.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -53,15 +60,17 @@
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"tsx": "^4.19.2"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
}
}

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-host-directory-picker-native/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native'

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'

View File

@@ -1,7 +1,7 @@
/** Registration/capability behavior of the native backend (the seam's cordis half). */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import NativeDirectoryPicker from '../src/index.ts'
describe('NativeDirectoryPicker', () => {

View File

@@ -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: 3749b238b56578ec68610bc13550760aa084bad6
README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735
README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522
README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
@@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note.
- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`,唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务`ctx.directoryPicker`是其 Service Definition。该服务只提供一个方法`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。
浏览原语失败时会抛出带类型的 `DirectoryPickerError``directory-unreadable``directory-exists``directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
@@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`
## 已知限制与暂缓事项
- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker",
"description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/directory-picker"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,11 +32,11 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -11,7 +11,7 @@
* @module @deepseek-ai/dsh-host-directory-picker
*/
import { Context, Service } from 'cordis'
import { Context, Service } from '@deepseek-ai/cordis'
/** The native interaction: one OS directory chooser on the host display. */
export interface DirectoryPickerNativeCapability {
@@ -115,7 +115,7 @@ export class DirectoryPickerError extends Error {
}
}
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
directoryPicker: DirectoryPicker
}

View File

@@ -1,6 +1,6 @@
/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker'

View File

@@ -1,7 +1,7 @@
/** Contract behavior the seam itself owns: registration identity and typed failures. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts'
import type { DirectoryPickerCapability } from '../src/index.ts'

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-frontend-static",
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/frontend-static"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,17 +32,17 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -14,8 +14,8 @@
import type { ServerResponse } from 'node:http'
import { readFile } from 'node:fs/promises'
import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Stable Cordis plugin name. */

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-frontend-static/invariant
*/
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static'

View File

@@ -11,9 +11,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import HttpServer from '@deepseek-ai/dsh-host-webserver'
import * as FrontendStatic from '../src/index.ts'

View File

@@ -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/webserver/README.md
README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd
README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508
README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474
README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler处理所有未被具名 route 命中的请求第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route插件 bundle 与 HMR热模块替换事件流是 moduleshmr 插件的 routedist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容URL 行属于 shell。
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route插件 bundle 与 HMR热模块替换事件流是 moduleshmr 插件的 routedist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch。该包从不打印内容URL 行属于 shell。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()``closeAllConnections()`,销毁所有受跟踪的升级 socket并仅在 HTTP server 与这些 socket 均已关闭后返回。

View File

@@ -1,8 +1,15 @@
{
"name": "@deepseek-ai/dsh-host-webserver",
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
"version": "0.0.1",
"private": true,
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/webserver"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
@@ -25,14 +32,14 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"dependencies": {
"schemastery": "^3.18.0"
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}

View File

@@ -12,10 +12,10 @@ import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
declare module 'cordis' {
declare module '@deepseek-ai/cordis' {
interface Context {
httpServer: HttpServerService
}
@@ -50,12 +50,11 @@ export interface Config {
}
/**
* The web-shape HTTP carrier service. Activation listens immediately (route
* registration order carries no request-facing semantics: named routes are
* composed to be disjoint, and the fallback seat answers anything not yet
* claimed during the boot window — 404 until its owner registers). A listen
* failure throws out of init — a FAILED fiber the boot's fail-loud sweep
* reports.
* The browser HTTP carrier service. Activation listens immediately. Route
* registration order does not affect requests because configured named routes
* must be distinct, and the fallback handler answers anything not yet claimed
* during startup with 404 until its owner registers. A listen failure rejects
* initialization, and the boot process reports the failed fiber.
*/
export class HttpServerService extends Service {
static Config: z<Config> = z.object({
@@ -224,8 +223,8 @@ export class HttpServerService extends Service {
})
})
// Node does not include upgraded sockets in closeAllConnections(), so the
// service tracks and destroys them as part of the same ownership boundary.
// Node does not include upgraded sockets in closeAllConnections(). The service
// owns them with the other connections, so it tracks and destroys them explicitly.
this.ctx.effect(() => async () => {
const serverClosed = new Promise<void>((resolve) => {
this.server.close(() => { resolve() })

View File

@@ -4,7 +4,7 @@
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-webserver'

View File

@@ -12,9 +12,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import HttpServer from '../src/index.ts'
let root: string | undefined