Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 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/README.md
README.md: 269a27f51c842f13bc11c175916b7be22db72bd2
README.zh.md: 559bf785eb45d59a30f676b98c14143c69d57edd
README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c
README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448

View File

@@ -15,3 +15,5 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend |
`apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam.
The subsystem references: [http-server.md](../../docs/subsystems/http-server.md) and [workspace.md](../../docs/subsystems/workspace.md) (the picker seam).

View File

@@ -6,12 +6,14 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承
| 包 | 职责 | ctx key |
|---|---|---|
| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议约 | `ctx.apiProxy` |
| [`apiproxy/`](apiproxy/README.md) | 共享宿主 API 网关和协议约 | `ctx.apiProxy` |
| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.httpServer` |
| [`frontend-static/`](frontend-static/README.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.httpServer` |
| [`directory-picker/`](directory-picker/README.md) | workspace 目录选择 seam | `ctx.directoryPicker` |
| [`directory-picker/`](directory-picker/README.md) | 工作区目录选择 seam | `ctx.directoryPicker` |
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
| [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 |
`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器HTTP 载体。选择器实现可在共享 seam 后互相替换。
子系统参考:[http-server.md](../../docs/subsystems/http-server.md) 与 [workspace.md](../../docs/subsystems/workspace.md)(选择器 seam

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: a3c9f214690144ec0f39a8690e4fd346f5e315e2
README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340
README.md: 44b8ee76b0da67ef1aaf24fb3d54e91d20fc064a
README.zh.md: 06b05aa64727c8bce08c7935040a1766f30b98ed

View File

@@ -2,19 +2,19 @@
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 `{provider, model, reasoningEffort?, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
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 default route (`api-gateway` settings section)
## The shared Agent default (`agent-default-model` Settings section)
`{provider, model, reasoningEffort?}` is also the gateway's user-settings section, registered under `api-gateway`: the composition entry is the `base` layer and `settings.yaml` layers the user's own choice over it. `workspaceRoot` is deliberately outside the section — a launcher fact, not a 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. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference.
A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created.
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.
`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.
`session.selectModel` saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved `ModelSelection`, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local.
The section's `reasoningEffort` has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.
The section's `reasoningEffort` has no counterpart in the agent-default-model plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.
The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise.
The stored selection is independent of catalog membership. A default naming an unavailable provider still reaches `session.models` as the session's `current`, allowing the selector to request a replacement instead of silently choosing another model. Conversely, an adapter may serve a model that its catalog does not advertise.
## Contract layer (`/api`)
@@ -24,15 +24,15 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). 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 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 provider/model/reasoning target, 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) owns the boundary rationale.
Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. `session.prompt` refuses on that same fact with `model-unavailable` rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless.
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.
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.
@@ -52,7 +52,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag
## 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)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless.
`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.
## Model Experience

View File

@@ -2,21 +2,21 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, reasoningEffort?, workspaceRoot?}`,提供 `ctx.apiProxy`。该包在设计上与传输方式无关不注册任何路由HTTP 等载体自行包装 `ctx.apiProxy`已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
所有客户端形态共用的 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-gateway` 设置段
## 共享 Agent 默认值(`agent-default-model` Settings 分节
`{provider, model, reasoningEffort?}` 同时是网关的用户设置段,注册在 `api-gateway` 之下:组合条目是 `base` 层,`settings.yaml` 把用户自己的选择叠加其上。`workspaceRoot` 刻意不在段内——它是启动器事实,不是偏好。
`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,不是模型偏好。
会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录`request/header`,最后是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后保存的默认值。
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新`request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
`session.selectModel` 会把接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。它存下来的是**解析后**的目标,因此适配器实体化出来的默认推理等级会按用户当时看到的样子钉住,日后适配器改了自己的默认值也不会悄悄移动已存的默认路由。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内
`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection`,包括适配器实体化的默认推理强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效
设置段里`reasoningEffort` 在插件配置中刻意没有对应字段seam 按字段把用户层合并到组合条目之上,缺席的键覆盖不了存在的键,因此组合层的推理等级会在此后每一次切到不支持推理的模型时继续存。推理等级的部署默认值属于适配器 profile,那里是按模型解析的
Settings 分节中`reasoningEffort` agent-default-model 插件配置中刻意没有对应字段seam 按字段把用户层合并到组合条目之上,因此缺席的键无法覆盖已有键,组合层的推理强度会在以后选择没有推理强度的模型时继续存。推理强度的部署默认值属于按模型解析的适配器 profile。
下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。
储的选择独立于目录成员关系。默认值指向不可用的提供方时,它仍会作为会话的 `current` 送到 `session.models`,让选择器请求用户重新选择而不是静默选用其他模型。反过来,适配器可以服务目录未公布的模型。
## 约层(`/api`
## 约层(`/api`
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`SSE 帧)和 `ClientResponse`POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi``HostApi``EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>``ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
@@ -24,17 +24,17 @@
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection`(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `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、日志中最新的提供方模型推理reasoning目标及谱系,再加入源 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)给出边界设计的理由。
会话模型路由属于会话领域约。`session.models`选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable``session.models` 还会报告 `routable`:当前目标的路由是否有适配器在服务。这一点刻意不分组推导——一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用;而适配器已经消失的路由什么都服务不了`session.prompt` 依据同一事实以 `model-unavailable` 拒绝,而不是把整条 pre-step 路径走完再在适配器内部失败;客户端禁用输入框只是提示性设计,这个方法始终可被调用。
会话模型选择属于会话领域约`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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
待处理的 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 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
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()` 中。
@@ -46,17 +46,17 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html``.htm``.xhtml``.svg`macOS 和桌面 Linux 会优先使用能够确定的默认浏览器无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`command.*``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、TUI、ACP、手动键入的文本共享同一条确定性路径没有专设的调用协议。`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、TUI、ACPAgent Client Protocol、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.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 偏好 `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` 错误应答这些领域。
## 载体层(`/client` + 根路径)
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包
## 模型体验
无。该包定义客户端与宿主间的协议约和载体,其中没有任何内容会进入模型请求。
无。该包定义客户端与宿主间的协议约和载体,其中没有任何内容会进入模型请求。
#### KV Cache 影响
@@ -69,4 +69,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime而每一次持久写入都会刷新它包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONLSQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。

View File

@@ -40,6 +40,7 @@
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",

View File

@@ -7,8 +7,9 @@ import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
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 { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
@@ -85,14 +86,6 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* The settings namespace carrying the user's default route. Named for the
* gateway rather than for the package, because this key is what a person reads
* and writes in `settings.yaml`; the row id in a composition happens to match
* but does not determine it.
*/
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
@@ -225,7 +218,7 @@ function isAborted(signal: AbortSignal): boolean {
* backwards from the window tail. Replacement copies never entered the
* conversation a reader sees — they restate a shadowed range for the model
* alone — so they consume no quota; the page stays one contiguous raw range,
* which keeps a compaction's log-only provenance on the same page as its
* which keeps a compaction's log-only `compact/summary` record on the same page as its
* replacement. The cut is the starting seq of the oldest message group (chunks
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
* includes the in-progress partial.
@@ -261,7 +254,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
/**
* Build the provider/model catalog over every registered route. Shared by the
* session-scoped `session.models` and host-scoped `llm.models`. Catalog
* membership stays advisory: an unlisted session target remains valid for
* membership stays advisory: an unlisted session selection remains valid for
* provider dispatch, but is not injected back into the selector after its
* owning catalog stops advertising it. Per-provider failures ride `failures`
* without failing the sound groups; groups that advertise nothing are dropped.
@@ -454,14 +447,14 @@ function directoryError(error: unknown): RpcError {
return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
}
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
/** Resolved Agent model and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
/**
* The route a session starts from when its own log names none. Read on
* The model selection a session starts from when its own log names none. Read on
* every access rather than captured, so a default saved during this process
* reaches the sessions that have not run a turn yet.
*/
defaultTarget: () => AgentLlmTarget
defaultModelSelection: () => ModelSelection
/**
* Record a selection as the new default. Either absent, or a closure that
* may itself decline — the gateway plugin always passes one, and it no-ops
@@ -470,7 +463,7 @@ export interface ApiProxyDefaults {
* reported and swallowed: the switch already applies to its own session,
* and undoing it because storage failed would be the worse outcome.
*/
persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
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. */
@@ -843,17 +836,17 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
/**
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the Host spine and Workspace registry mounted.
* @param defaults - host routing and project-directory defaults.
* @param defaults - Agent model and project-directory defaults.
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
/** The seed route each create/resume declares; re-read so it never goes stale. */
/** The seed model each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultTarget()
const { provider, model } = defaults.defaultModelSelection()
return { provider, model }
}
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
const selections = new WeakMap<Agent, WebModelSelectionRef>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership and explicit title checks with Workspace mutations. */
@@ -871,29 +864,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/**
* Install or return the session-local target that prompt assembly snapshots.
* Install or return the session-local model selection that prompt assembly snapshots.
*
* Precedence, resolved on EVERY read rather than seeded once: a selection
* made in this process, else the session's own latest logged request/header,
* else the live host default. Re-reading is what keeps the two tiers honest
* in both directions a session that has run a turn derives its route from
* its log forever after, so changing the default never retargets it; and a
* session still blank (New Session reuses one rather than minting another)
* starts from a default saved after it was created. There is no create-time
* else the live Agent default. Re-reading keeps the two tiers exact in both
* directions: a session with a recorded request derives its selection from
* its log, while a blank session (New Session reuses one rather than minting
* another) reads any default saved after it was created. There is no create-time
* per-session override tier on this wire — if one returns (a create-options
* contribution), it must fold in between the selection and the log.
*/
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
function selectionFor(agent: Agent): WebModelSelectionRef {
const installed = selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentLlmTarget | undefined
const target: WebLlmTargetRef = {
get current(): AgentLlmTarget {
let picked: ModelSelection | undefined
const selection: WebModelSelectionRef = {
get current(): ModelSelection {
if (picked !== undefined) return picked
// Incrementally folded by the session, so a per-step read costs
// O(new events) rather than a rescan.
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaults.defaultTarget()
if (logged === undefined) return defaults.defaultModelSelection()
return {
provider: logged.provider,
model: logged.model,
@@ -902,21 +894,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
: { reasoningEffort: logged.reasoningEffort },
}
},
set current(next: AgentLlmTarget) {
set current(next: ModelSelection) {
picked = next
},
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
targets.set(agent, target)
return target
installModelSelection(agent.ctx, selection)
selections.set(agent, selection)
return selection
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
function installSelection(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
targetFor(agent)
selectionFor(agent)
}
const hasSubagentOwner = (
@@ -927,7 +919,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
apiRemoteSubagentOwnershipError(sessionId)
const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
inspectApiRemoteSession(ctx, sessionId)
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installTarget })
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection })
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
@@ -936,7 +928,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
// mints the wire frame (the Service Definition package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
// subscription unwinds with this gateway's fiber.
ctx.inject(['sessionProjections'], (projectionCtx) => {
@@ -1193,7 +1185,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: agentOptions(),
setup: installTarget,
setup: installSelection,
})).agent
}
@@ -1206,7 +1198,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessionId,
agentOptions: agentOptions(),
meta: { cwd },
setup: installTarget,
setup: installSelection,
})).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
@@ -1352,10 +1344,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
/**
* Whether an adapter currently serves this route, and therefore whether a
* session pointed at it can start a turn. Catalog membership cannot answer
* Whether an adapter currently serves this provider, and therefore whether
* a session selecting it can start a turn. Catalog membership cannot answer
* it: an adapter may serve a model its own catalog stopped advertising, so
* a route missing from the groups is not the same as one nothing serves.
* a provider missing from the groups is not the same as one nothing serves.
* A composition with no llm registry at all cannot judge and says yes —
* the dispatch it would have refused fails on its own terms.
*/
@@ -1366,7 +1358,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/**
* Resolve the addressed agent for a turn-starting method and refuse when no
* adapter serves its current route: a route nothing serves cannot start a
* adapter serves its current selection: a provider nothing serves cannot start a
* turn, and letting it try spends the whole pre-step path to fail inside
* the adapter with a message about registration. Refusing here names the
* model the session is pointed at while the draft is still in the composer.
@@ -1379,13 +1371,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return { refused: err(request, found.error) }
const agent = found.agent
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
const selection = selectionFor(agent).current
if (!routeServed(selection.provider)) {
return {
refused: err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
message: `no adapter serves provider "${selection.provider}"; select a model for this session`,
details: { provider: selection.provider, model: selection.model },
}),
}
}
@@ -1444,7 +1436,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
}
/** Map one redacted seam descriptor to its wire view. */
/** Map one redacted settings descriptor to its wire view. */
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
return {
ns: String(descriptor.ns),
@@ -1546,8 +1538,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
sessions: {
// Attached sessions summarize from memory; persisted-but-unattached (cold)
// sessions merge in from the persistence store so history survives restarts.
// Legacy logs without a cwd (pre-project stance) are not served every
// session now records its project at create time.
// Logs without a cwd are not served; every session records its project
// at create time.
async list(request) {
return ok(request, { items: await listVisibleSessionSummaries() })
},
@@ -1631,9 +1623,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
)
}
// Host visibility is the authorization boundary. Consume the
// provider's globally ranked stream rather than binding every
// visible id into one SQLite statement, then re-check complete
// provenance before emitting any snippet.
// provider's globally ranked results rather than binding every
// visible id into one SQLite statement, then require each hit to
// name a visible session and a current message from that same
// session before emitting its snippet.
for (const hit of page.items) {
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
if (
@@ -1760,7 +1753,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const current = selectionFor(found.agent).current
const { groups, failures } = await buildModelCatalog(ctx)
const routable = routeServed(current.provider)
return ok(request, { current: { ...current }, routable, groups, failures })
@@ -1791,16 +1784,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
const selected: AgentLlmTarget = {
const selected: ModelSelection = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
selectionFor(found.agent).current = selected
try {
await defaults.persistDefaultTarget?.(selected)
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)}`,
@@ -1910,7 +1903,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
seedLength: cut,
},
agentOptions: agentOptions(),
setup: installTarget,
setup: installSelection,
})
} catch (error: unknown) {
return err(request, {
@@ -1947,7 +1940,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
try {
if (hasImage) {
const current = targetFor(agent).current
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, {
@@ -2222,6 +2215,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return subagentPromptError(request, error, signal)
}
},
// Deliberately no catalog, history, persistence, or parent Agent lookup:
// the core primitive alone authorizes the durable address against the
// live Activation, which is what keeps a live child interruptible while
// its parent Agent is offline. Absent targets are accepted no-ops there.
interrupt(request) {
const { parentSessionId, childSessionId } = request.payload
try {
ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
return Promise.resolve(err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
}))
}
return Promise.resolve(err(request, {
code: 'internal',
message: 'subagent interrupt failed',
details: {},
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {
@@ -2239,7 +2257,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// 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 seam that carried the name
// `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
@@ -2375,8 +2393,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host: {
describe(request) {
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
const route = defaults.defaultTarget()
// TODO: version should read apps/cli's package.json; placeholder for now.
const selection = defaults.defaultModelSelection()
return Promise.resolve(ok(request, {
version: '0.0.1',
// Same source as session.create's fallback: the UI's default project
@@ -2384,8 +2402,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
cwd: defaults.cwd,
// Read live for the same reason: this is what the NEXT session will
// start from, so a saved default has to be what it reports.
provider: route.provider,
model: route.model,
provider: selection.provider,
model: selection.model,
attachedSessions: ctx.agents.list().length,
}))
},
@@ -2914,11 +2932,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it. The
// gateway's own section is the other such source: it names the
// route every session with no logged one resolves to, so an
// Agent default section is the other such source: it names the
// selection every session with no logged one resolves to, so an
// externally edited default (another tab, a hand-edited
// settings.yaml) has to reach an open selector too.
if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) {
if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) {
queue.push(frame({ type: 'host/models-changed' }))
}
}),

View File

@@ -49,7 +49,7 @@ export interface EventsApi {
* attached session, then replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
* generic projection pair (history-tail projections block + session/projection frames).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
mux(request: RpcRequest<{ since?: Record<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>

View File

@@ -37,13 +37,14 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
SessionSearchItem,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'

View File

@@ -3,8 +3,8 @@
* surfaces. `llm.providers` merges the configurable-provider directory
* (which providers CAN be configured, and where their settings live) with the
* live route registry; `llm.models` is the session-independent model catalog
* (the same groups as `session.models`, without the per-session current
* target). Both invalidate on the `host/models-changed` frame.
* (the same groups as `session.models`, without a per-session selection).
* Both invalidate on the `host/models-changed` frame.
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'

View File

@@ -37,6 +37,7 @@ export interface RpcMethodMap {
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'subagent.interrupt': SubagentsApi['interrupt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']

View File

@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -141,12 +141,12 @@ export const sessionHistoryRequestSchema = z.object({
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/** Complete provider/model target. */
export const modelTargetSchema = z.object({
/** Complete provider/model selection. */
export const modelSelectionSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelTarget>>
}) satisfies z.ZodType<Wire<ModelSelection>>
/** One adapter-owned reasoning effort. */
export const modelReasoningEffortSchema = z.object({
@@ -225,7 +225,7 @@ export const sessionModelsRequestSchema = z.object({
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
current: modelSelectionSchema,
routable: z.boolean(),
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
@@ -241,7 +241,7 @@ export const sessionSelectModelRequestSchema = z.object({
/** session.selectModel response value. */
export const sessionSelectModelValueSchema = z.object({
selected: modelTargetSchema,
selected: modelSelectionSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */

View File

@@ -59,8 +59,8 @@ export type PromptContentPart =
| { type: 'text'; text: string }
| { type: 'image'; mediaType: ImageMediaType; data: string; name?: string }
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Complete model selection for one session. */
export interface ModelSelection {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
@@ -121,8 +121,8 @@ export interface ModelCatalogFailure {
/** Detached model-directory snapshot for one session. */
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/** Model selection for the session's next assembled step. */
current: ModelSelection
/**
* Whether an adapter currently serves `current.provider`, and therefore
* whether this session can start a turn at all. Deliberately NOT derivable
@@ -222,7 +222,7 @@ export interface SessionsApi {
* Reads a window of history events; page boundaries align to append-origin message
* boundaries: one page = all raw events owned by a whole number of such messages (including
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
* `maxMessages`, so a compaction's `compact/summary` record stays on the page of its replacement. The tail
* page (beforeSeq absent) additionally carries the in-flight
* partial — chunk events already emitted for the last unfinalized message.
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
@@ -246,7 +246,7 @@ export interface SessionsApi {
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete target for this session. Exact model metadata
* Selects the complete model selection for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory. Session-backed subagents reject with `agent-busy`.
*/
@@ -256,7 +256,7 @@ export interface SessionsApi {
model: string
reasoningEffort?: string
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
Promise<RpcResponse<{ selected: ModelSelection }>>
/**
* Renames a session: appends a `session/title` event with the `user`

View File

@@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
/** subagent.interrupt request payload. */
export const subagentInterruptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
/** subagent.interrupt response value. */
export const subagentInterruptValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */

View File

@@ -40,6 +40,11 @@ export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Uniform acknowledgement that one interrupt request was admitted. */
export interface SubagentInterruptReceipt {
accepted: true
}
/** Durable parent/child address that selects subagent transport in the client. */
export type SubagentAddress =
& {
@@ -94,4 +99,17 @@ export interface SubagentsApi {
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
/**
* Interrupts a live continuable child's current turn under the address's
* durable direct-parent authority, without requiring a live parent Agent,
* consulting the catalog, or resuming anything. Fire-and-return: `accepted`
* acknowledges the admitted cancel signal, not target quiescence, so the
* child may remain visibly running briefly. Unclaimed queued follow-ups are
* kept and parked; an absent, idle, or already-completed target is likewise
* `accepted`.
*/
interrupt(
request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
): Promise<RpcResponse<SubagentInterruptReceipt>>
}

View File

@@ -59,6 +59,7 @@ import {
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentInterruptValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
@@ -98,6 +99,7 @@ export interface IApiClient {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -174,6 +176,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'subagent.interrupt': subagentInterruptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -411,6 +414,7 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
}
readonly host: IApiClient['host'] = {

View File

@@ -61,6 +61,7 @@ import {
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentInterruptRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
@@ -97,6 +98,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

View File

@@ -7,28 +7,24 @@
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*
* The gateway also owns the `api-gateway` settings section: the route a
* session starts from when its own log names none. The composition entry is
* the shipped default and the section layers the user's choice over it, so
* switching models in a conversation is what sets the default for the next
* one. Sessions that have already logged a route are never retargeted by it.
* The gateway consumes `ctx.agentDefaultModel`, the transport-independent default
* shared with direct front doors. 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 type { AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
import { createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
@@ -38,62 +34,12 @@ declare module 'cordis' {
}
}
/**
* The `api-gateway` settings section: the route a session starts from when its
* own log names none. `workspaceRoot` is deliberately not part of it — that is
* a launcher fact, not a preference.
*/
export interface DefaultRouteSettings {
/** Default provider route for created agents. */
provider: string
/** Default model id. */
model: string
/** Default reasoning effort; absence preserves the adapter/provider default. */
reasoningEffort?: string
}
/**
* Gateway plugin config: host-level agent routing and Workspace creation root.
*
* `reasoningEffort` is deliberately absent, so the section carries one field
* the composition cannot. The seam resolves a section by MERGING the user
* layer over the composition entry per field, and an absent key cannot
* override a present one — so a composition-set effort would survive every
* later switch to a model that has none, and strand it for the next session
* to fail on. Effort is a per-model fact anyway: a deployment default belongs
* on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own),
* which resolves per model rather than per gateway.
*/
/** Gateway plugin config: the Host-only Workspace creation root. */
export interface Config {
/** Default provider route for created agents. */
provider: string
/** Default model id. */
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
}
/**
* Schema of the `api-gateway` section, exported because it IS that section's
* contract — the shape anything reading or writing `settings.yaml` addresses.
*/
export const DEFAULT_ROUTE_SCHEMA: z<DefaultRouteSettings> = z.object({
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
})
/** Project the stored/composed section onto the agent-facing target shape. */
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
return {
provider: settings.provider,
model: settings.model,
...settings.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) },
}
}
/**
* 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
@@ -101,13 +47,11 @@ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
})
@@ -127,30 +71,9 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
// The composition entry is the shipped default; the settings section
// layers the user's own choice over it, and a deployment without a
// settings provider simply keeps the entry.
const entry: DefaultRouteSettings = { provider: config.provider, model: config.model }
let route: () => DefaultRouteSettings = () => entry
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
setSource: (current) => {
route = current
},
// Nothing registration-level derives from the default: every consumer
// reads it through the thunk at the moment it needs a route.
onChange: () => {},
})
const api = createApiProxy(ctx, {
defaultTarget: () => routeTarget(route()),
// Wholesale, never a merge: switching to a model with no reasoning
// effort must clear a stored one, and a merged patch would strand it
// for the next session to fail on. This clears it because the entry
// below the user layer carries no effort to re-inherit — the reason
// `Config` deliberately has no such field. The section holds no
// secrets, so there is nothing a replace can collaterally drop.
persistDefaultTarget: async (target) => {
await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target)
},
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
@@ -165,8 +88,8 @@ export class ApiProxyService extends Service implements ApiProxy {
this.credentials = api.credentials
this.llm = api.llm
this.events = api.events
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
// createApiProxy returns closures (no `this` capture), so the bind is
// behavior-neutral.
this.respond = api.respond.bind(api)
}
}

View File

@@ -157,7 +157,7 @@ async function openNativePathWithIntent(
* with the default browser when the path names a document a browser renders.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform, environment, and runner seam for deterministic tests.
* @param internals - Platform, environment, and runner hooks for deterministic tests.
*/
export function openNativePath(
path: string,
@@ -172,7 +172,7 @@ export function openNativePath(
* so a YAML association with a browser cannot consume the gesture.
* @param path - absolute or host-resolvable text-document path.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @param internals - Platform and runner hooks for deterministic tests.
*/
export function openNativeTextFile(
path: string,

View File

@@ -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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()

View File

@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry)
return {
ctx,
api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},

View File

@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
return undefined
},
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
@@ -365,13 +365,13 @@ describe('subagent ownership fence', () => {
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
// Pre-#1569 stores classify a child only through the descriptor event and
// carry no header `origin`; the pre-release decision stops recognizing
// them, so the ownership fence lets generic resume reach the registry
// instead of answering `agent-busy`.
// Stores whose headers predate `origin` classify a child only through the
// descriptor event; the pre-release decision stops recognizing them, so
// the ownership fence lets generic resume reach the registry instead of
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
@@ -518,7 +518,7 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.plugin(UserInteractionService)
const session = ctx.sessions.create(sid('session-throwing'))
// A live structural stub whose delivery verbs throw synchronously, the
// shape a disposed loop presents at this seam.
// shape a disposed loop presents at this gateway boundary.
ctx.agents.register({
id: session.id,
session,
@@ -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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)

View File

@@ -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 = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

View File

@@ -22,9 +22,10 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
@@ -43,7 +44,7 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
return response.result.error
}
/** In-memory settings provider: the seam base class owns all tested behavior. */
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
class MemorySettings extends Settings {
doc: Record<string, unknown>
@@ -366,9 +367,10 @@ describe('settings domain', () => {
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
// Editing `models` changes no route, so llm/adapters-updated never fires
// and an open model picker kept serving the old catalog. And storing an
// override equal to the resolved value emits nothing on settings/updated,
// so another tab never learned the field became overridden.
// and an open model picker would keep serving the stale catalog. Storing
// an override equal to the resolved value emits nothing on
// settings/updated, so another tab would never learn the field became
// overridden.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
@@ -398,21 +400,21 @@ describe('settings domain', () => {
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
})
it('invalidates the model catalog when the gateway default route changes', async () => {
it('invalidates the model catalog when the Agent default selection changes', async () => {
const ctx = await harness()
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
provider: z.string().required(),
model: z.string().required(),
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
const api = createApiProxy(ctx, DEFAULTS)
// The gateway's own section names the route every session with no logged
// one resolves to, so an externally edited default — another tab, a
// The shared section names the selection every blank session resolves to,
// so an externally edited default — another tab, a
// hand-edited settings.yaml — has to reach an open selector as well.
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'api-gateway' },
{ type: 'host/settings-changed', ns: 'agent-default-model' },
{ type: 'host/models-changed' },
])
})

View File

@@ -1,108 +0,0 @@
/**
* The `api-gateway` settings section over a REAL settings provider: the
* composition entry as the base layer, the wholesale replace the gateway
* persists with, and the fallback when the provider detaches. The other model
* specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
* this is the only place the layering itself is exercised.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
import type { DefaultRouteSettings } from '../src/index.ts'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
/** Mount the gateway's own section wiring over a live provider. */
async function boot(entry: DefaultRouteSettings) {
const ctx = new Context()
const fiber = ctx.plugin(MemorySettings)
await fiber.await()
let route: () => DefaultRouteSettings = () => entry
const consumer = ctx.plugin(function section(child: Context) {
installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
setSource: (current) => { route = current },
onChange: () => {},
})
})
await consumer.await()
const settings = ctx.get('settings')
if (settings === undefined) throw new Error('settings provider did not mount')
return { ctx, fiber, consumer, settings, read: () => route() }
}
describe('the api-gateway default-route section', () => {
it('resolves the composition entry until the user layer overrides it', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
expect(bench.read()).toEqual({
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
await bench.ctx.fiber.dispose()
})
it('clears a stored effort when the next switch has none', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
expect(bench.read().reasoningEffort).toBe('high')
// The whole reason the gateway persists with `replace` rather than a merge
// patch — and the reason `Config` carries no effort for the base layer to
// re-inherit here. A stranded effort would fail the next session's first
// request against a model that does not support it.
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-plain',
})
expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
await bench.ctx.fiber.dispose()
})
it('layers a hand-written partial section over the entry', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
// Someone editing settings.yaml by hand may name only the model. The
// entry supplies the provider, which is what makes this legal — and is
// exactly why an effort in the entry could never be cleared, so there
// is none to inherit.
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the provider detaches', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large',
})
expect(bench.read().provider).toBe('acme-gateway')
// A deployment that loses its settings provider keeps serving the route it
// was composed with rather than the one it can no longer read.
await bench.fiber.dispose()
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.ctx.fiber.dispose()
})
})

View File

@@ -82,7 +82,7 @@ function liveAgent(
}
const api = (ctx: Context) => createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }),
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -254,7 +254,7 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('installs the latest logged model target before the child can run', async () => {
it('installs the latest logged model selection before the child can run', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {

View File

@@ -1,6 +1,6 @@
/**
* Web session model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-target restoration without stale
* provider-local catalog failures, logged-selection restoration without stale
* catalog injection, advisory pass-through models, and the prompt-assembly
* boundary for a running selection change.
*/
@@ -154,7 +154,7 @@ describe('Web session model selection', () => {
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -201,7 +201,7 @@ describe('Web session model selection', () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -244,7 +244,7 @@ describe('Web session model selection', () => {
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -271,13 +271,13 @@ describe('Web session model selection', () => {
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
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, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -312,7 +312,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, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -378,11 +378,11 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('reads the host default live for a session whose log names no route', async () => {
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -400,14 +400,14 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('keeps a session that logged a route on it when the host default moves', async () => {
it('keeps a session on its logged selection when the Agent default differs', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -423,9 +423,9 @@ describe('Web session model selection', () => {
const saved: unknown[] = []
let reject = false
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
persistDefaultTarget: (target) => {
saved.push(target)
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: (selection) => {
saved.push(selection)
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
@@ -458,7 +458,7 @@ describe('Web session model selection', () => {
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -491,7 +491,7 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})

View File

@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
}
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

View File

@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
}
}

View File

@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

View File

@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }
@@ -165,7 +165,7 @@ describe('session.search', () => {
expect(searchSessions).not.toHaveBeenCalled()
})
it('rejects snippets whose provider provenance violates the Host filters', async () => {
it('rejects snippets whose recorded provider violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -84,13 +91,13 @@ function bench(options: {
locate: () => undefined,
})
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that seam quiet while these tests pin history reads.
// no-op disposer keeps that feed quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
@@ -236,9 +236,9 @@ describe('mux live view computation', () => {
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
@@ -247,7 +247,7 @@ describe('mux live view computation', () => {
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: log-only provenance immediately followed by the
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
@@ -277,7 +277,7 @@ describe('mux live view computation', () => {
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.result.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's provenance is readable on
// The range stays contiguous, so the checkpoint's summary record is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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

@@ -100,7 +100,7 @@ async function harness(
// object per harness mirrors the seam's stability contract.
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: workspaceRoot,
workspaceRoot,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },

View File

@@ -41,7 +41,7 @@ function scriptedApi(overrides: {
history: r => ok(r, {
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
models: r => ok(r, {
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
@@ -67,6 +67,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -252,6 +253,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -134,6 +134,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -440,6 +443,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

View File

@@ -192,7 +192,7 @@ describe('sessions domain schemas', () => {
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({

View File

@@ -38,6 +38,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-default-model"
},
{
"path": "../../core/session"
},
@@ -45,19 +48,19 @@
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
"path": "../../session/session-persistence"
},
{
"path": "../../session-projection/session-projection"
"path": "../../session/session-projection"
},
{
"path": "../../session-projection/session-projection-cache"
"path": "../../session/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
"path": "../../session/session-title"
},
{
"path": "../../session-query/session-query"
@@ -69,13 +72,13 @@
"path": "../../skill/skill"
},
{
"path": "../../ui/commands"
"path": "../../interaction/commands"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../ui/user-interaction"
"path": "../../interaction/user-interaction"
},
{
"path": "../../workspace/workspace"

View File

@@ -773,8 +773,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onKeyDown={(event) => {
if (event.key !== 'Escape' || pathDraft === null) return
// stopPropagation keeps the card-scope Escape from the Modal's
// document listener — the same containment the input previously
// provided for itself.
// document listener.
event.stopPropagation()
// Escape while the input holds focus is about to unmount it; with
// focus already parked on a row, that row survives the cancel and

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
README.md: 4dd0c79d080fe097d063cfd2200e30aafc2d2d42
README.zh.md: 8ebf2a6e978ad042e522e7e2831a508501d3eca5
README.zh.md: e7d0ec99c1aaca8f22f8d5dc29fcbddd73d9f7c0

View File

@@ -4,13 +4,13 @@
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于 live 状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(`single` kind)。
**双面包**浏览器端`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流 slot 注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并通过 slot 的属主交互约定上报唯一结果(所选路径/取消/失败)。两个目录流程声明必须同时处于有效状态,任一贡献才会安装。因此一行 cordis.yml 同时组合原生交互的两侧;客户端不包含任何能力类型进行的分支,挂载第二个流程包会在加载期失败(slot 的 kind `single`)。
## 模型体验
无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。

View File

@@ -42,7 +42,7 @@ function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
/**
* Open the platform directory picker.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @param internals - Platform and runner hooks for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickNativeDirectory(
@@ -70,8 +70,8 @@ export async function pickNativeDirectory(
// The koffi-backed IFileOpenDialog child process — the modern picker with
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
// whose availability the install guarantees, so there is no fallback
// tier: any failure surfaces as-is (the former PowerShell chain was
// removed — see the simplification Agent Note).
// tier: any failure surfaces as-is (no PowerShell fallback tier; see
// .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md).
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
return await pickDialog(signal)
}

View File

@@ -1,6 +1,6 @@
/**
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
* conversation over an injectable bindings seam, so every outcome path
* conversation over injectable platform bindings, so every outcome path
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
* any platform. The koffi-backed bindings live in
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.

View File

@@ -60,7 +60,7 @@ function assertNever(value: never): never {
/**
* Open the modern Win32 folder picker off the event loop.
* @param signal - caller lifetime; abort closes the dialog and rejects.
* @param internals - worker/window seams for deterministic tests.
* @param internals - Worker/window hooks for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickWin32Directory(

View File

@@ -3,4 +3,4 @@
# 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: 4f7c20e25a4e64f63c611fc56eeec3f249ad67e3
README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735

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`),唯一约方法 `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) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
浏览原语失败时会抛出带类型的 `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 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。

View File

@@ -1,5 +1,5 @@
/**
* The `ctx.directoryPicker` seam: how the web-GUI host lets an operator
* Service Definition for the `ctx.directoryPicker` capability seam: how the web-GUI host lets an operator
* select a workspace directory. Backends differ in interaction shape, not
* just mechanism, so the service exposes a discriminated capability instead
* of one method set: a `native` backend opens one OS chooser on the

View File

@@ -10,7 +10,10 @@ export const name = 'host-directory-picker-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: this stateless seam owns the capability vocabulary, while backends and the RPC consumer own observations. */
/**
* No runtime invariant: this stateless Service Definition owns the capability
* vocabulary, while backends and the RPC consumer own observations.
*/
const install: InvariantInstaller = () => {}
/**

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/frontend-static/README.md
README.md: 82ba5a2cd0937e2c24505648aa1e3daec6bf2ece
README.zh.md: 1130aa7cc241ee5245fceaba0ef66fcf95871e67
README.zh.md: 205c44838e40697cf1429a198d313391ea7acbae

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 `application/octet-stream` 提供GETHEAD 之外的方法在没有匹配的具名 route 时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换(`applyIndexTaps`),启动 manifest元数据清单就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
Web 壳的 SPA dist 服务器:一个函数插件(配置为 `{distIndex}`),占据 [webserver](../webserver/README.md) 的唯一回退席位,并按壳层锁定的语义服务已构建的前端目录——越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 `application/octet-stream` 提供GETHEAD 之外的方法在没有匹配的具名路由时返回 405。每个 index 响应都会经过 webserver 已注册的 index 转换`applyIndexTaps`),启动 manifest元数据清单就是经这条路径送达页面的。`distIndex` 是组合应用的组装事实:[`dsh-web-app`](../../bundle/web-app/README.md) 通过前端包的 exports 解析它并挂载本插件;部署绝不硬编码它。
回退席位只有单一所有者(第二次占据会抛错),并受 effect 作用域约束dispose资源释放插件的 fiber 会释放席位,此后无人占据的 webserver 回答 404。

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: b6dccf2f81c9e2f0b9f53264eafe724edb560f07
README.zh.md: dbfe420013ed67c48e47048341f020864aeef16a
README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd
README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508

View File

@@ -8,8 +8,6 @@ The package knows no harness concepts and serves no files: the `/api` HTTP bridg
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.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
## Model Experience
None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.

View File

@@ -2,14 +2,12 @@
[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)` 认领唯一的回退席位,应答所有未被具名 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 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
该包不了解任何 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。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()``closeAllConnections()`,销毁所有受跟踪的升级 socket并仅在 HTTP server 与这些 socket 均已关闭后返回。
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map再统一发布因此基线失败会保留先前的图。这样即时重建不会消失在异步建立的监听基线中重命名窗口会把路径标记为脏保留最近一次成功基线并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
## 模型体验
无。该包只是浏览器与其他插件所注册 HTTPupgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。

View File

@@ -3,7 +3,7 @@
* server plus the `httpServer` service (HTTP and upgrade route registries,
* index transform taps, and the single fallback seat for everything no route
* claims). Knows no harness concepts and serves no files; the composing
* application's frontend plugin owns dist serving through the fallback seam.
* application's frontend plugin owns dist serving through the fallback hook.
* Web shape only — Electron loads dist over file:// and carries fetch over an
* IPC bridge. This package never prints: the URL line belongs to the shell.
*/