refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 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: 1c3b6ab3192fe35a5532183414e45d1b02325e57
README.zh.md: a062d5fce055e3266953993d532a86bec1375377
README.md: 28a93e095f7c2a0fb08d6d10a8f5fe03f321dcbb
README.zh.md: 9601e559d16aa7ed50a11865e8297310fbed3df7

View File

@@ -7,8 +7,8 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
| Package | Role | ctx key |
|---|---|---|
| [`apiproxy/`](apiproxy/README.md) | Shared host API gateway and wire contract | `ctx.apiProxy` |
| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.httpServer` |
| [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.httpServer` |
| [`webserver/`](webserver/README.md) | HTTP route carrier | `ctx.webServer` |
| [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.webServer` |
| [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam | `ctx.directoryPicker` |
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
@@ -17,4 +17,4 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
`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).
The subsystem references: [web-server.md](../../docs/subsystems/web-server.md) and [workspace.md](../../docs/subsystems/workspace.md) (the picker seam).

View File

@@ -7,8 +7,8 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承
| 包 | 职责 | ctx key |
|---|---|---|
| [`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` |
| [`webserver/`](webserver/README.md) | HTTP 路由载体 | `ctx.webServer` |
| [`frontend-static/`](frontend-static/README.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.webServer` |
| [`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` |
@@ -17,4 +17,4 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承
`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器HTTP 载体。选择器实现可在共享 seam 后互相替换。
子系统参考:[http-server.md](../../docs/subsystems/http-server.md) 与 [workspace.md](../../docs/subsystems/workspace.md)(选择器 seam
子系统参考:[web-server.md](../../docs/subsystems/web-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: a40cc52e5caf1e4557d17e9fd2c1ac589dce30dd
README.zh.md: 735c184aef603ae8713dd5147559d0499d0e1030
README.md: 9467ec288ae597a43eaf954393005ef81ec02c66
README.zh.md: 8194bf0a72f52cf9824a067f12040167eaf005da

View File

@@ -24,7 +24,7 @@ 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 `compact/summary` record 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 `compaction/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 no other domain's 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. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
@@ -40,7 +40,7 @@ Session model selection is a session-domain contract. `session.models` returns t
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
@@ -76,7 +76,7 @@ None; this package neither assembles nor sends a provider request.
- **Forwarded Remote events are parasitic on this legacy frame union** — `host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)).
- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries.
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).

View File

@@ -24,7 +24,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent然后按追加来源的消息边界分页`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志 `compaction/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 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
@@ -40,7 +40,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
待处理的 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()` 修改队列。认领操作的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found``session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession``reported``outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found``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()` 中。
@@ -76,7 +76,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **转发的 Remote 事件寄居在这套 legacy 帧联合里**`host/remote-event` 住在 `HostFrame` 中,是为了让投递路径复用现有宿主流、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`。将来宿主流整体搬离本包时,该帧随之搬走,消费端契约不受影响([原委](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md))。
- **待处理交互状态位于宿主侧**wire 使用 POST `/api/respond``RpcReceipt``src/api-proxy.ts` 中的表只处理问题,不包含审批条目。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **预留 seam 不进入 `RpcMethodMap`**`prompt.mode: 'inject'``job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。

View File

@@ -65,10 +65,10 @@
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"fflate": "^0.8.2",
@@ -84,7 +84,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}

View File

@@ -39,7 +39,7 @@ import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -59,9 +59,9 @@ import {
} from './api/session-search.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
import type {} from '@deepseek-ai/dsh-tasks'
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
// Type-only: resolves `ctx.get('tasks')` to the background job registry.
import type {} from '@deepseek-ai/dsh-jobs'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
import type {} from '@deepseek-ai/dsh-session-projection-cache'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
@@ -91,8 +91,8 @@ import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } fr
import { RpcId } from './api/rpc.ts'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
} from '@deepseek-ai/dsh-user-questions'
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import {
ApiRemoteSessionNotFound as SessionNotFound,
@@ -119,7 +119,7 @@ const DEFAULT_MAX_MESSAGES = 50
* is deferred work.
*/
const WEB_SETTINGS_NAMESPACES = [
'agent-loop', 'bash', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek',
'agent-loop', 'shell', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek',
] as const
/** Provider work budget: at most 100 calls and 2,000 inspected hits. */
@@ -277,7 +277,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 `compact/summary` record on the same page as its
* which keeps a compaction's log-only `compaction/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.
@@ -476,17 +476,17 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
/**
* Project registry snapshots onto the wire view, dropping the three internal
* fields {@link TaskView} documents as absent.
* fields {@link JobView} documents as absent.
*/
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
return snapshots.map(task => ({
id: task.id,
kind: task.kind,
label: task.label,
status: task.status,
...task.detail === undefined ? {} : { detail: task.detail },
startedAt: task.startedAt,
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
function jobViews(snapshots: readonly JobSnapshot[]): JobView[] {
return snapshots.map(job => ({
id: job.id,
kind: job.kind,
label: job.label,
status: job.status,
...job.detail === undefined ? {} : { detail: job.detail },
startedAt: job.startedAt,
...job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt },
}))
}
@@ -651,7 +651,7 @@ interface PendingQuestion {
sessionId: SessionId
questions: AskUserQuestionItem[]
resolve: (answer: AskUserQuestionAnswer) => void
reject: (error: UserInteractionError) => void
reject: (error: UserQuestionError) => void
signal?: AbortSignal
onAbort?: () => void
}
@@ -1295,11 +1295,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
const disposeProvider = ctx.userInteraction.registerProvider({
const disposeProvider = ctx.userQuestions.registerProvider({
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const sessionId = request.agent?.id
if (sessionId === undefined) {
return Promise.reject(new UserInteractionError(
return Promise.reject(new UserQuestionError(
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
@@ -1310,7 +1310,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
const onAbort = (): void => {
claimQuestion(pending, 'cancelled')
reject(new UserInteractionError(
reject(new UserQuestionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
}
pending.onAbort = onAbort
@@ -1328,10 +1328,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
disposeProvider()
for (const pending of [...pendingQuestions.values()]) {
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'web user-interaction provider was disposed', 'ASK_ABORTED'))
pending.reject(new UserQuestionError(
'web user-questions provider was disposed', 'ASK_ABORTED'))
}
}, 'api-proxy: user-interaction provider')
}, 'api-proxy: user-questions provider')
// --- Approval pending registry ------------------------------------------
// The proxy is the approval channel for every agent this host owns: an ask
@@ -1439,7 +1439,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
const workspaces = ctx.workspace.list()
const workspaces = ctx.workspaceRegistry.list()
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
if (direct !== undefined || source.header.origin !== 'subagent') return direct
@@ -1638,9 +1638,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => {
const existing = await ctx.workspace.resolveByPath(path)
const existing = await ctx.workspaceRegistry.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path), created: true }
return { workspace: await ctx.workspaceRegistry.create(path), created: true }
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation
@@ -1786,7 +1786,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Missing-service report shared by the settings domain (skills-domain stance). */
function settingsAbsent(): RpcError {
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} }
}
/** Open one Host-resolved target and map native failures onto the wire vocabulary. */
@@ -2086,7 +2086,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
let workspace: Workspace | undefined
if (request.payload.workspaceId !== undefined) {
workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
workspace = ctx.workspaceRegistry.get(brandWorkspaceId(request.payload.workspaceId))
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
@@ -2720,8 +2720,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
workspace: {
list(request) {
return Promise.resolve(ok(request, {
items: ctx.workspace.list().map(workspaceView),
archivedSessionIds: [...ctx.workspace.archivedSessionIds],
items: ctx.workspaceRegistry.list().map(workspaceView),
archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds],
}))
},
@@ -2744,7 +2744,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async rename(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
const title = payload.title.trim()
// Uniqueness AND the same-title no-op both ride the create chain so
@@ -2753,7 +2753,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// still lands afterwards.
const operation = workspaceCreationChain.then(async () => {
if (title === workspace.title) return
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
if (ctx.workspaceRegistry.list().some(other => other.id !== workspace.id && other.title === title)) {
throw new WorkspaceNameConflictError(title)
}
await workspace.setTitle(title)
@@ -2777,7 +2777,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async delete(request) {
const { workspaceId } = request.payload
const operation = workspaceCreationChain.then(() =>
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
ctx.workspaceRegistry.delete(brandWorkspaceId(workspaceId)))
workspaceCreationChain = operation.then(() => undefined, () => undefined)
if (!await operation) return workspaceNotFound(request, workspaceId)
return ok(request, { deleted: true as const })
@@ -2786,7 +2786,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async insertBefore(request) {
const { workspaceId, beforeWorkspaceId } = request.payload
try {
const workspaceIds = await ctx.workspace.insertBefore(
const workspaceIds = await ctx.workspaceRegistry.insertBefore(
brandWorkspaceId(workspaceId),
beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId),
)
@@ -2799,7 +2799,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
const workspace = ctx.workspaceRegistry.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
try {
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
@@ -2823,7 +2823,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async archiveSession(request) {
const { sessionId } = request.payload
try {
await ctx.workspace.archiveSession(sessionId)
await ctx.workspaceRegistry.archiveSession(sessionId)
} catch (error: unknown) {
// Only the registry's unknown-session rejection is the business
// code; storage/durability failures propagate as internal errors.
@@ -2834,7 +2834,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { sessionId },
})
}
return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] })
return ok(request, { archivedSessionIds: [...ctx.workspaceRegistry.archivedSessionIds] })
},
},
@@ -3376,12 +3376,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// a session with no live Agent owns no tasks, so it correctly sees only
// the unowned ones, and listing never revives a cold session. An empty
// set sends nothing — absence is how the client reads "no tasks".
const tasks = ctx.get('tasks')
if (tasks !== undefined) {
const jobs = ctx.get('jobs')
if (jobs !== undefined) {
for (const session of ctx.sessions.list()) {
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
const views = jobViews(jobs.list(ctx.agents.get(session.id)))
if (views.length > 0) {
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views }))
}
}
}
@@ -3416,29 +3416,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// session born after the stream opened missed the baseline loop.
// Unowned tasks are visible to it from birth, so without this it
// would show none until the next registry change.
const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id)))
const views = jobs === undefined ? [] : jobViews(jobs.list(ctx.agents.get(session.id)))
if (views.length > 0) {
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
queue.push(frame({ type: 'session/jobs', sessionId: session.id, jobs: views }))
}
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
...jobs === undefined ? [] : [jobs.onJobsChanged((owner) => {
if (owner !== undefined) {
// The exact owner instance the fence compares against, so the
// push stays correct even while that Agent's scope is tearing
// down and a lookup by id would already miss.
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
queue.push(frame({ type: 'session/jobs', sessionId: owner.id, jobs: jobViews(jobs.list(owner)) }))
return
}
// An unowned task is visible to every caller, so every subscribed
// session's set changed with it.
for (const session of ctx.sessions.list()) {
queue.push(frame({
type: 'session/tasks',
type: 'session/jobs',
sessionId: session.id,
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
jobs: jobViews(jobs.list(ctx.agents.get(session.id))),
}))
}
})],
@@ -3451,7 +3451,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host(_request, signal) {
const queue = new FrameQueue<RpcRequest<HostFrame>>()
const committedWorkspaces = ctx.workspace.list()
const committedWorkspaces = ctx.workspaceRegistry.list()
const committedWorkspaceIds = new Set(
committedWorkspaces.map(workspace => String(workspace.id)),
)
@@ -3459,7 +3459,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// Frame-dedup baseline, same posture as committedWorkspaceIds: the
// stream opens against the current set; workspace.list re-baselines
// reconnecting clients, so only later changes need frames.
let archivedSessionIds = ctx.workspace.archivedSessionIds
let archivedSessionIds = ctx.workspaceRegistry.archivedSessionIds
const disposers = [
ctx.on('session/created', (session: Session) => {
queue.push(frame({
@@ -3491,7 +3491,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
&& state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index])
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
const workspace = ctx.workspace.get(workspaceId)
const workspace = ctx.workspaceRegistry.get(workspaceId)
if (workspace === undefined) {
throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
}
@@ -3633,7 +3633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
pending.reject(new UserQuestionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
return Promise.resolve({ accepted: true })
}

View File

@@ -5,7 +5,7 @@
*/
import { z } from 'zod'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
import type { HostFrame, MuxFrame } from './events.ts'
import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
@@ -13,10 +13,10 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { taskViewSchema } from './tasks.schema.ts'
import { taskViewSchema } from './jobs.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question fields validated strictly against core dsh-user-interaction. */
/** Question fields validated strictly against core dsh-user-questions. */
export const askUserQuestionItemSchema = z.object({
id: z.string(),
question: z.string(),
@@ -45,7 +45,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
// Non-empty by wire contract: the user-interaction service rejects empty
// Non-empty by wire contract: the user-questions service rejects empty
// batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
@@ -59,7 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
message: messageSchema,
})),
}),
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
z.object({ type: z.literal('session/jobs'), sessionId: sessionIdSchema, jobs: z.array(taskViewSchema) }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),

View File

@@ -6,7 +6,7 @@
* signal is a local stream-control parameter, independent of the request (never on the wire).
*/
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { Message } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -14,7 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { TaskView } from './tasks.ts'
import type { JobView } from './jobs.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
@@ -83,7 +83,7 @@ export type MuxFrame =
*/
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* Complete set of background tasks this session can see, after every registry
* Complete set of background jobs this session can see, after every registry
* commit that changes it: registration, the stopping transition, settlement,
* and owner-disposal removal. The registry is process-local and holds no
* durable event, so — exactly like `session/queue` — the whole snapshot is
@@ -95,7 +95,7 @@ export type MuxFrame =
* still sends `[]`, since that transition is the only one absence cannot
* express.
*/
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the

View File

@@ -48,7 +48,7 @@ export type {
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { TaskView } from './tasks.ts'
export type { JobView } from './jobs.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'

View File

@@ -1,15 +1,15 @@
/**
* tasks domain zod schemas: the branded task id and the wire view carried by
* `session/tasks` frames.
* tasks domain zod schemas: the branded job id and the wire view carried by
* `session/jobs` frames.
*/
import { z } from 'zod'
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
import type { TaskView } from './tasks.ts'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { JobView } from './jobs.ts'
import type { Wire } from './rpc.schema.ts'
/** TaskId: one brand cast after non-empty string validation. */
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
/** JobId: one brand cast after non-empty string validation. */
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<JobId>
/**
* One wire task view. `kind` stays an open string because producer plugins
@@ -30,4 +30,4 @@ export const taskViewSchema = z.object({
detail: z.string().optional(),
startedAt: z.number().int().nonnegative(),
finishedAt: z.number().int().nonnegative().optional(),
}) satisfies z.ZodType<Wire<TaskView>>
}) satisfies z.ZodType<Wire<JobView>>

View File

@@ -1,22 +1,22 @@
/**
* Browser-safe background-task domain contract. The registry's live records
* Browser-safe background-job domain contract. The registry's live records
* never cross the wire; a view is the subset a human list needs, minted fresh
* per push.
*/
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
/**
* One background task as the client sees it.
* One background job as the client sees it.
*
* Three registry fields are deliberately absent. `ownerSession` is redundant
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
* bit with no user meaning; `outputLimitBytes` is producer-owned model
* presentation policy that never reaches a human surface.
*/
export interface TaskView {
export interface JobView {
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
id: TaskId
id: JobId
/**
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, ). Kept as a bare
* string because producer plugins extend the kind map by declaration merging,

View File

@@ -5,12 +5,12 @@
*/
import { z } from 'zod'
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-interaction/types'
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types'
import type { QuestionResponsePayload } from './questions.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
/** AskUserQuestionAnswer validated strictly against core dsh-user-interaction. */
/** AskUserQuestionAnswer validated strictly against core dsh-user-questions. */
export const askUserQuestionAnswerSchema = z.object({
answers: z.array(z.object({
id: z.string(),

View File

@@ -1,11 +1,11 @@
/**
* questions domain contract. The question requested frame is a
* server-request whose rpcId is the question's stable logical id (minted when the host accepts
* ask(); core user-interaction has no request-level id); the answer is a client-response
* ask(); core user-questions has no request-level id); the answer is a client-response
* echoing that rpcId, with no resource id in the payload (rpcId suffices).
*/
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-interaction/types'
import type { AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**

View File

@@ -250,7 +250,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 `compact/summary` record stays on the page of its replacement. The tail
* `maxMessages`, so a compaction's `compaction/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

View File

@@ -63,7 +63,7 @@ export interface Config {
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
'tools', 'userQuestions', 'workspaceRegistry',
]
static Config: z<Config> = z.object({

View File

@@ -22,7 +22,7 @@
import { Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
@@ -34,7 +34,7 @@ export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel =
/** The services a session-log export needs (the live-session store is optional). */
export interface SessionLogExportDeps {
readonly sessionQuery: SessionQueryService | undefined
readonly sessionQuery: SessionQueryEngine | undefined
readonly sessionPersistence: SessionPersistence | undefined
readonly attachments: AttachmentStore | undefined
readonly sessions: SessionStore | undefined
@@ -42,7 +42,7 @@ export interface SessionLogExportDeps {
/** The export services narrowed to the mounted ones streaming actually reads. */
export interface SessionLogExportReady {
readonly sessionQuery: SessionQueryService
readonly sessionQuery: SessionQueryEngine
readonly sessionPersistence: SessionPersistence
readonly attachments: AttachmentStore
readonly sessions: SessionStore | undefined

View File

@@ -12,7 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
import type { HostFrame } from '../src/api/events.ts'
import {
@@ -110,7 +110,7 @@ async function harness(
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
@@ -380,7 +380,7 @@ describe('agentPreset.select', () => {
// The host-stream opener reads the committed-workspace baseline; this
// spec owns preset identity, so the stub suffices (api-proxy-commands
// precedent).
ctx.provide('workspace', { list: () => [] } as never)
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const abort = new AbortController()
const frames: HostFrame[] = []
const stream = api.events.host(request({}), abort.signal)

View File

@@ -12,7 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -24,7 +24,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -212,13 +212,13 @@ describe('approval pending registry', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
}, { inject: ['sessions', 'agents', 'userQuestions', 'approval'] }))
await fiber.await()
const abort = new AbortController()
const mux = openMux(api, abort)

View File

@@ -13,10 +13,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Side-effect type imports: the knob-event SessionEventMap merges.
import type {} from '@deepseek-ai/dsh-permission'
import type {} from '@deepseek-ai/dsh-permission-presets'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -31,7 +31,7 @@ function request<P>(payload: P): RpcRequest<P> {
async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
return {
ctx,

View File

@@ -11,11 +11,11 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import {
PersistenceCoordinator,
@@ -42,7 +42,7 @@ describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
@@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -126,7 +126,7 @@ describe('cold history recovery view', () => {
it('shows in-memory interruption repair without activating the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
const stored: StoredPrefix<never> = {
@@ -188,7 +188,7 @@ describe('Remote Agent and Session lookup policy', () => {
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
@@ -230,7 +230,7 @@ describe('Remote Agent and Session lookup policy', () => {
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const coldId = sid('session-remote-cold-child')
const coldMeta = header(coldId, 1000, {
parentSession: sid('session-parent'),
@@ -267,9 +267,9 @@ describe('Remote Agent and Session lookup policy', () => {
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
await expect(coldFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure)
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
await expect(liveFailure).rejects.toBeInstanceOf(TypeRTLookupFailure)
await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure)
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
expect(resume).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
@@ -281,7 +281,7 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
parentSession: sid('session-parent'),
@@ -346,7 +346,7 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const sessionId = sid('session-legacy-child')
const meta = header('session-legacy-child', 1000, {
parentSession: sid('session-parent'),
@@ -387,7 +387,7 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
@@ -445,7 +445,7 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
type: 'subagent/descriptor',
@@ -473,7 +473,7 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
@@ -549,7 +549,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
@@ -569,7 +569,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
@@ -589,7 +589,7 @@ describe('sessions.prompt synchronous rejection', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
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 gateway boundary.
@@ -622,7 +622,7 @@ describe('sessions.prompt synchronous rejection', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {

View File

@@ -11,13 +11,13 @@ import z from '@deepseek-ai/schemastery'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Settings, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { Credentials } from '@deepseek-ai/dsh-credentials'
import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
@@ -45,10 +45,10 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
}
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
class MemorySettings extends Settings {
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
@@ -88,10 +88,10 @@ class MemorySettings extends Settings {
}
/** In-memory credential provider with an env-shadow double for the rejection path. */
class MemoryCredentials extends Credentials {
class MemoryCredentials extends CredentialProvider {
private readonly values = new Map<string, string>()
constructor(ctx: ConstructorParameters<typeof Credentials>[0], options?: { shadowed?: string[] }) {
constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
super(ctx)
this.shadowed = new Set(options?.shadowed ?? [])
}
@@ -177,10 +177,10 @@ async function harness(options?: {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolRuntime)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
// Model-provider namespaces plus the explicit Web preference and product
@@ -192,7 +192,7 @@ async function harness(options?: {
}
// Host-stream opener reads the committed-workspace baseline; the stub
// suffices — the real workspace composition is api-proxy-workspace.spec's.
ctx.provide('workspace', { list: () => [] } as never)
ctx.provide('workspaceRegistry', { list: () => [] } as never)
return ctx
}
@@ -240,7 +240,7 @@ describe('settings domain', () => {
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.settings.describe(request({})))
expect(error.code).toBe('internal')
expect(error.message).toContain('dsh-settings-local')
expect(error.message).toContain('dsh-settings-file')
})
it('describes layered redacted namespaces with their secret slots', async () => {
@@ -344,7 +344,7 @@ describe('settings domain', () => {
ctx.settings.register(settingsNamespace('ui-conversation'), z.object({
busyEnter: z.union(['queue', 'steer']).default('queue'),
}))
ctx.settings.register(settingsNamespace('bash'), z.object({
ctx.settings.register(settingsNamespace('shell'), z.object({
timeoutMs: z.number().default(120_000),
}))
ctx.settings.register(settingsNamespace('agent-loop'), z.object({
@@ -358,7 +358,7 @@ describe('settings domain', () => {
const value = expectOk(await api.settings.describe(request({})))
expect(value.namespaces.map(view => view.ns)).toEqual([
'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation',
'bash', 'agent-loop', 'web-search-deepseek',
'shell', 'agent-loop', 'web-search-deepseek',
])
const permission = expectOk(await api.settings.mutate(request({
ns: 'permission',
@@ -381,7 +381,7 @@ describe('settings domain', () => {
})))
expect(conversation.value).toEqual({ busyEnter: 'steer' })
const bash = expectOk(await api.settings.mutate(request({
ns: 'bash',
ns: 'shell',
ops: [{ op: 'set', path: ['timeoutMs'], value: 5_000 }],
})))
expect(bash.value).toEqual({ timeoutMs: 5_000 })

View File

@@ -9,7 +9,7 @@ import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -27,8 +27,8 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => workspaces } as never)
await ctx.plugin(UserQuestionService)
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {

View File

@@ -3,7 +3,7 @@
* baseline is sent only for a session that has tasks, every registry change
* pushes that owner's whole set, an unowned change fans out to every
* subscribed session, the projection drops the three internal snapshot
* fields, a composition without `ctx.tasks` emits nothing, and listing never
* fields, a composition without `ctx.jobs` emits nothing, and listing never
* resumes a cold session.
*/
@@ -13,14 +13,14 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
type JobFrame = Extract<MuxFrame, { type: 'session/jobs' }>
/**
* A producer whose settlement the test drives. `cancel` deliberately does not
@@ -28,7 +28,7 @@ type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
* test supplies the terminal outcome and its detail.
*/
function producer(label = 'sleep 60') {
let settle!: (outcome: TaskOutcome) => void
let settle!: (outcome: JobOutcome) => void
// A stream producer, so the carrier CAN consume the cursor if it ever calls
// `read()`; `reads` is what proves it never does.
const reads = { count: 0 }
@@ -37,21 +37,21 @@ function producer(label = 'sleep 60') {
label,
run: () => ({
cancel: () => {},
done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
readOutput: () => { reads.count += 1; return 'stolen output' },
}),
}
return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
if (withRegistry) {
await ctx.plugin(LocalTaskService)
ctx.tasks.attachController('api-proxy-test')
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('api-proxy-test')
}
const session = ctx.sessions.create()
const agent = {
@@ -67,21 +67,21 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
/** Drain the mux until `count` session/tasks frames arrived, then abort. */
/** Drain the mux until `count` session/jobs frames arrived, then abort. */
async function collect(
iterable: AsyncIterable<RpcRequest<MuxFrame>>,
count: number,
abort: AbortController,
): Promise<TaskFrame[]> {
): Promise<JobFrame[]> {
const frames: MuxFrame[] = []
for await (const envelope of iterable) {
frames.push(envelope.payload)
if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
if (frames.filter(frame => frame.type === 'session/jobs').length >= count) abort.abort()
}
return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
return frames.filter((frame): frame is JobFrame => frame.type === 'session/jobs')
}
describe('session/tasks subscription baseline', () => {
describe('session/jobs subscription baseline', () => {
it('is omitted for a session with no tasks — absence is the empty set', async () => {
const { ctx, session } = await harness(true)
const abort = new AbortController()
@@ -94,22 +94,22 @@ describe('session/tasks subscription baseline', () => {
}
})()
await drained
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false)
expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
void session
})
it('carries the live set for a session that already has tasks when the stream opens', async () => {
const { ctx, session, agent } = await harness(true)
ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
const abort = new AbortController()
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
const [baseline] = await collect(stream, 1, abort)
expect(baseline?.sessionId).toBe(session.id)
expect(baseline?.tasks).toHaveLength(1)
const [task] = baseline?.tasks ?? []
expect(task?.startedAt).toBeTypeOf('number')
expect({ ...task, startedAt: 0 }).toEqual({
expect(baseline?.jobs).toHaveLength(1)
const [job] = baseline?.jobs ?? []
expect(job?.startedAt).toBeTypeOf('number')
expect({ ...job, startedAt: 0 }).toEqual({
id: 'bash-1',
kind: 'bash',
label: 'pnpm run build',
@@ -119,7 +119,7 @@ describe('session/tasks subscription baseline', () => {
})
})
describe('session/tasks change pushes', () => {
describe('session/jobs change pushes', () => {
it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent } = await harness(true)
const proxy = api(ctx)
@@ -128,16 +128,16 @@ describe('session/tasks change pushes', () => {
const collected = collect(stream, 3, abort)
const p = producer()
const id = ctx.tasks.start({ ...p.spec, owner: agent })
ctx.tasks.kill(id, agent, 'test')
const id = ctx.jobs.start({ ...p.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
const frames = await collected
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
// Terminal detail rides the same whole-set push; no separate signal.
expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
})
it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
@@ -146,10 +146,10 @@ describe('session/tasks change pushes', () => {
const abort = new AbortController()
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
const collected = collect(stream, 1, abort)
ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
const [frame] = await collected
const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
const fields: readonly string[] = Object.keys(frame?.jobs[0] ?? {})
expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
})
@@ -161,12 +161,12 @@ describe('session/tasks change pushes', () => {
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
const collected = collect(stream, 2, abort)
ctx.tasks.start(producer('open to every caller').spec)
ctx.jobs.start(producer('open to every caller').spec)
const frames = await collected
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
})
it('serves a cold session the unowned set without resuming it', async () => {
@@ -183,14 +183,14 @@ describe('session/tasks change pushes', () => {
const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
const collected = collect(stream, 1, abort)
ctx.tasks.start(producer().spec)
ctx.jobs.start(producer().spec)
await collected
expect(loaded).toBe(false)
expect(ctx.agents.get(coldId)).toBeUndefined()
})
})
describe('session/tasks without the registry', () => {
describe('session/jobs without the registry', () => {
it('emits no frames at all, so the client renders no entry point', async () => {
const { ctx, session } = await harness(false)
const proxy = api(ctx)
@@ -205,14 +205,14 @@ describe('session/tasks without the registry', () => {
})()
session.append('turn/start', { turn: 1 })
await drained
expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
expect(frames.some(frame => frame.type === 'session/jobs')).toBe(false)
})
})
describe('session/tasks never consumes model output', () => {
describe('session/jobs never consumes model output', () => {
it('drives the whole lifecycle without calling the single consuming cursor', async () => {
// `ctx.tasks.read()` consumes the one output cursor, so a carrier read
// silently takes bytes the model's `task_output` will never see. The
// `ctx.jobs.read()` consumes the one output cursor, so a carrier read
// silently takes bytes the model's `job_output` will never see. The
// failure is invisible at the call site, which is why this asserts the
// count rather than trusting review.
const { ctx, agent } = await harness(true)
@@ -222,8 +222,8 @@ describe('session/tasks never consumes model output', () => {
const collected = collect(stream, 3, abort)
const p = producer()
const id = ctx.tasks.start({ ...p.spec, owner: agent })
ctx.tasks.kill(id, agent, 'test')
const id = ctx.jobs.start({ ...p.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await collected
@@ -233,18 +233,18 @@ describe('session/tasks never consumes model output', () => {
it('reads nothing while minting the subscription baseline either', async () => {
const { ctx, agent } = await harness(true)
const p = producer()
ctx.tasks.start({ ...p.spec, owner: agent })
ctx.jobs.start({ ...p.spec, owner: agent })
const abort = new AbortController()
const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
const [baseline] = await collect(stream, 1, abort)
expect(baseline?.tasks).toHaveLength(1)
expect(baseline?.jobs).toHaveLength(1)
expect(p.reads.count).toBe(0)
})
})
describe('session/tasks baseline for a session born after the stream opened', () => {
describe('session/jobs baseline for a session born after the stream opened', () => {
it('carries the already-visible unowned set to the new session', async () => {
const { ctx } = await harness(true)
const proxy = api(ctx)
@@ -253,11 +253,11 @@ describe('session/tasks baseline for a session born after the stream opened', ()
// One unowned task exists before the new session is created; the subscribe
// frame clears the client mirror, so the baseline has to follow it.
ctx.tasks.start(producer('visible to every caller').spec)
ctx.jobs.start(producer('visible to every caller').spec)
const created = ctx.sessions.create()
const frames = await collect(stream, 2, abort)
const forNew = frames.filter(frame => frame.sessionId === created.id)
expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller')
})
})

View File

@@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
@@ -18,7 +18,7 @@ import type {
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -84,8 +84,8 @@ async function harness(logged?: {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },

View File

@@ -18,7 +18,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -50,7 +50,7 @@ const lastUserUnit = (): ProjectionDefinition<'test/last-user', LastUserState> =
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -11,7 +11,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
return {
ctx,
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
@@ -74,7 +74,7 @@ describe('question response validation', () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
const asked = ctx.userQuestions.ask({
agent: agent(ctx),
questions: [{
id: 'targets',
@@ -98,7 +98,7 @@ describe('question response validation', () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
const asked = ctx.userQuestions.ask({
agent: agent(ctx),
questions: [{
id: 'target',

View File

@@ -14,7 +14,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -31,7 +31,7 @@ async function composed(withTitles = true): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
if (withTitles) {
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
}

View File

@@ -11,7 +11,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import {
SessionQueryError,
type SessionSearchHit,
@@ -63,7 +63,7 @@ async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
return ctx
}

View File

@@ -96,7 +96,7 @@ function bench(options: {
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that feed quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
ctx.provide('userQuestions', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})

View File

@@ -13,12 +13,12 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -68,8 +68,8 @@ async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolRuntime)
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
@@ -249,7 +249,7 @@ describe('mux live view computation', () => {
const shadowed = [...session.surface.nodes]
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compact/summary', {
const summary = appendExtension(session, 'compaction/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,

View File

@@ -9,7 +9,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
@@ -52,7 +52,7 @@ function stubAgent(session: Session): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
runMaintenance: job => job(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -69,7 +69,7 @@ async function harness(
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
@@ -327,7 +327,7 @@ describe('workspace.insertBefore', () => {
const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
const abort = new AbortController()
const listWorkspaces = vi.spyOn(ctx.workspace, 'list')
const listWorkspaces = vi.spyOn(ctx.workspaceRegistry, 'list')
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
expect(listWorkspaces).toHaveBeenCalledTimes(1)
@@ -394,7 +394,7 @@ describe('session creation and Workspace membership', () => {
it('retains a published session when attachment fails and repairs it on retry', async () => {
const { api, ctx, root } = await harness()
const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const workspace = ctx.workspace.list()[0]
const workspace = ctx.workspaceRegistry.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
const sessionId = SessionId('session-attach-retry')

View File

@@ -453,8 +453,8 @@ describe('events frame schemas', () => {
},
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'session/tasks', sessionId: 's', tasks: [] },
{ type: 'session/tasks', sessionId: 's', tasks: [
{ type: 'session/jobs', sessionId: 's', jobs: [] },
{ type: 'session/jobs', sessionId: 's', jobs: [
{ id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 },
{ id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 },
] },
@@ -468,12 +468,12 @@ describe('events frame schemas', () => {
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
// A producer kind stays an open string, but the closed status set and
// the identity/label bounds are the carrier's own wire contract.
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
{ type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] },
{ type: 'session/jobs', sessionId: 's', jobs: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})

View File

@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { unzipSync, strFromU8 } from 'fflate'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
@@ -77,7 +77,7 @@ async function buildApi(
} = {},
) {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await ctx.plugin(UserQuestionService)
const query = services.query ?? true
const persistence = services.persistence ?? true
if (query) {

View File

@@ -75,7 +75,7 @@
"path": "../../skill/skill"
},
{
"path": "../../tasks/tasks"
"path": "../../jobs/jobs"
},
{
"path": "../../interaction/commands"
@@ -84,7 +84,7 @@
"path": "../../interaction/user-approval"
},
{
"path": "../../interaction/user-interaction"
"path": "../../interaction/user-questions"
},
{
"path": "../../workspace/workspace"
@@ -93,7 +93,7 @@
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/native-command"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md
README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b
README.zh.md: 0d444cdb292aa7d3e5aa6fd361140f37f3a2df52
README.md: fd162e0652c7eb43de111323e93e0be936d39e2f
README.zh.md: 02334d432490b81493aa7179ce68857456a61bf6

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The **adaptive chooser** of the [directory-picker seam](../directory-picker/README.md): a node-half-only plugin that resolves the host's situation once at boot and mounts the matching dual-face backend — [`-native`](../directory-picker-native/README.md) or [`-browse`](../directory-picker-browse/README.md) — as a real Loader entry in the in-memory root tree (never persisted to a config file; the root tree's `write()` is a no-op). Because the backend arrives as an ordinary entry, its browser half is discovered by the client module table exactly as a config-row's would be, so the seam's one-row-swaps-both-faces invariant holds for the resolved choice. Unloading the chooser removes the entry again, unloading both faces with it.
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `httpServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
Resolution is one pure boot-time sample (`resolveDirectoryPickerBackend`), exported for reuse. `native` requires every signal that the operator can see the host display and the native backend can serve it: a loopback-only bind (read from the injected `webServer`; an all-interfaces bind admits remote browsers no OS chooser can reach), no SSH launch (`SSH_CONNECTION`/`SSH_TTY` unset or blank — under SSH port-forwarding the chooser would open on the unattended server), and a servable display session — assumed on darwin/win32; on linux `DISPLAY`/`WAYLAND_DISPLAY` plus a zenity or kdialog binary on `PATH` (the probe is one more boot-time fact); never on any other platform, since the native backend drives exactly darwin/win32/linux. Anything ambiguous resolves to `browse`, which works everywhere. The sample happens exactly once per boot so the mounted capability stays stable for the service lifetime, as the seam requires. Pinning an interaction is not a config field here — compose the `-native` or `-browse` row directly instead of this one, the seam's documented swap point; mounting the chooser **and** a backend row together fails loud (duplicate `directoryPicker` service, duplicate client flow in the `single` holes).
## Model Experience

View File

@@ -4,7 +4,7 @@
[目录选择 seam](../directory-picker/README.md) 的**自适应选择器**:一个只有 node 半侧的插件,在启动时一次性判定宿主处境,并把匹配的双面后端——[`-native`](../directory-picker-native/README.md) 或 [`-browse`](../directory-picker-browse/README.md)——作为真实的 Loader 条目挂进内存根树(绝不持久化到配置文件;根树的 `write()` 是 no-op。由于后端以普通条目的形式到达其 browser half 被 client 模块表发现的方式与配置行完全相同因此对判定出的选择seam 的“一行同时换两面”不变式依然成立。卸载该选择器会再次移除该条目,连同两面一起卸载。
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `httpServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION``SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上以及可服务的显示会话——darwinwin32 上视为存在linux 上要求 `DISPLAY``WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwinwin32linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native``-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会明确报错(重复的 `directoryPicker` 服务、`single` 类 slot 中的重复 client 流程)。
判定是一次纯函数的启动时采样(`resolveDirectoryPickerBackend`),已导出供复用。`native` 要求“操作者看得到宿主屏幕、且 native 后端能服务它”的全部信号:仅回环的绑定(从注入的 `webServer` 读取;全网卡绑定会接入任何 OS 选择器都触及不到的远程浏览器);非 SSH 启动(`SSH_CONNECTION``SSH_TTY` 未设置或为空——SSH 端口转发下选择器会弹在无人值守的服务器上以及可服务的显示会话——darwinwin32 上视为存在linux 上要求 `DISPLAY``WAYLAND_DISPLAY`,外加 `PATH` 上有 zenity 或 kdialog 二进制(该探查是又一项启动时事实);其余任何平台上都不成立,因为 native 后端驱动的平台恰为 darwinwin32linux。任何含糊情形都判定为处处可用的 `browse`。采样每次启动恰好发生一次,因此挂载的能力在服务生命周期内保持稳定,符合 seam 的要求。固定某种交互在这里不是配置字段——直接组合 `-native``-browse` 行来替代本行,那才是 seam 文档化的切换点;同时挂载选择器**和**某个后端行会明确报错(重复的 `directoryPicker` 服务、`single` 类 slot 中的重复 client 流程)。
## 模型体验

View File

@@ -34,7 +34,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
@@ -45,7 +45,7 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",

View File

@@ -12,7 +12,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
// Empty type imports carry the `loader` and `httpServer` Context merges for the reads below.
// Empty type imports carry the `loader` and `webServer` Context merges for the reads below.
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { canExecute, hasLinuxChooserBinary } from './probe.ts'
@@ -25,8 +25,8 @@ export { resolveDirectoryPickerBackend } from './resolve.ts'
/** Cordis plugin name. */
export const name = 'directory-picker-auto'
/** Required services: the effective bind host (`httpServer`) and the entry tree the backend mounts into (`loader`). */
export const inject = ['httpServer', 'loader']
/** Required services: the effective bind host (`webServer`) and the entry tree the backend mounts into (`loader`). */
export const inject = ['webServer', 'loader']
/**
* Host backend package per resolved kind — fixed composition vocabulary, not a
@@ -49,7 +49,7 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
*/
export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
native: '@deepseek-ai/dsh-client-ui-directory-picker-native',
browse: '@deepseek-ai/dsh-client-ui-directory-picker',
browse: '@deepseek-ai/dsh-client-ui-directory-picker-browse',
}
/**
@@ -57,11 +57,11 @@ export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
* surface as Loader entries; the effect's disposer removes both entries and
* joins their fibers' teardown, so unloading this plugin returns only after
* both faces of the mounted interaction (and their dependents) quiesced.
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
* @param ctx - cordis context carrying the injected `webServer` and `loader`.
*/
export async function apply(ctx: Context): Promise<void> {
const backend = resolveDirectoryPickerBackend({
bindHost: ctx.httpServer.host,
bindHost: ctx.webServer.host,
platform: process.platform,
env: process.env,
linuxChooser: hasLinuxChooserBinary(process.env.PATH, canExecute),

View File

@@ -49,7 +49,7 @@ const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native'
const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker'
const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-browse'
/**
* Loader-visible stand-in for a client surface package: the surfaces belong to

View File

@@ -21,7 +21,7 @@
"path": "../webserver"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -15,7 +15,7 @@
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -1,5 +1,5 @@
/**
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
* Keyless built-artifact guard (the `dsh-workflow-worker-thread` built-worker
* shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its
* real koffi requires. POSIX hosts prove the load path end to end through
* the deterministic ole32 rejection; win32 skips (a real dialog would

View File

@@ -15,7 +15,7 @@
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/native-command"

View File

@@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'
/**
* Node-only backend. The Win32 dialog worker builds as its own CJS entry
* (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver,
* (mirroring dsh-workflow-worker-thread's worker): path-loaded by the driver,
* inlining the dialog logic while koffi stays an external native require.
*/
export default defineConfig([

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

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/frontend-static/README.md
README.md: 82ba5a2cd0937e2c24505648aa1e3daec6bf2ece
README.zh.md: 205c44838e40697cf1429a198d313391ea7acbae
README.md: e53a369384fb8d7fe020e99da0d2cacca59e8afd
README.zh.md: 7018d80182d477f2bdff7b1d8510971dc98c71d5

View File

@@ -1,4 +1,4 @@
# `@deepseek-ai/dsh-frontend-static`
# `@deepseek-ai/dsh-host-frontend-static`
English | [中文](README.zh.md)

View File

@@ -1,4 +1,4 @@
# `@deepseek-ai/dsh-frontend-static`
# `@deepseek-ai/dsh-host-frontend-static`
[English](README.md) | 中文

View File

@@ -1,5 +1,5 @@
{
"name": "@deepseek-ai/dsh-frontend-static",
"name": "@deepseek-ai/dsh-host-frontend-static",
"description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback",
"version": "0.0.1-rc.2",
"publishConfig": {

View File

@@ -1,5 +1,5 @@
/**
* @deepseek-ai/dsh-frontend-static — SPA dist server over the webserver
* @deepseek-ai/dsh-host-frontend-static — SPA dist server over the webserver
* fallback seat: serves the built frontend directory with the semantics the
* Web shell locked at step1 — traversal outside the dist root is 403, any
* miss falls back to index.html with HTTP 200 (SPA routing), unknown
@@ -8,7 +8,7 @@
* injection). The dist location is workspace knowledge of the composing
* application, so `distIndex` is typically supplied through a `!!js`
* expression, never hardcoded by a deployment.
* @module @deepseek-ai/dsh-frontend-static
* @module @deepseek-ai/dsh-host-frontend-static
*/
import type { ServerResponse } from 'node:http'
@@ -22,7 +22,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
export const name = 'frontend-static'
/** Service required before the fallback seat can be claimed. */
export const inject = ['httpServer']
export const inject = ['webServer']
/** Plugin config: the dist anchor. */
export interface Config {
@@ -87,15 +87,15 @@ export async function serveStatic(
/**
* Claim the webserver fallback seat and serve the dist.
* @param ctx - plugin context carrying the httpServer service.
* @param ctx - plugin context carrying the webServer service.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
const distIndex = config.distIndex
const distRoot = dirname(distIndex)
const renderIndex = async (): Promise<string> =>
ctx.httpServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
ctx.effect(() => ctx.httpServer.registerFallback(async (req, res) => {
ctx.webServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => {
// Non-GET/HEAD without a matching named route is 405 (fallback-only
// semantics: named routes own their method handling).
if (req.method !== 'GET' && req.method !== 'HEAD') {

View File

@@ -1,15 +1,15 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-frontend-static`.
* @module @deepseek-ai/dsh-frontend-static/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-host-frontend-static`.
* @module @deepseek-ai/dsh-host-frontend-static/invariant
*/
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-frontend-static'
/** Cordis companion plugin name. */
export const name = 'frontend-static-invariant'
export const name = 'host-frontend-static-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']

View File

@@ -44,7 +44,7 @@ async function loadComposition(): Promise<Context> {
" host: '127.0.0.1'",
' port: 0',
'- id: frontend',
" name: '@deepseek-ai/dsh-frontend-static'",
" name: '@deepseek-ai/dsh-host-frontend-static'",
' config:',
` distIndex: '${distIndex}'`,
'',
@@ -56,7 +56,7 @@ async function loadComposition(): Promise<Context> {
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-host-webserver', HttpServer],
['@deepseek-ai/dsh-frontend-static', FrontendStatic],
['@deepseek-ai/dsh-host-frontend-static', FrontendStatic],
])
context.loader.internal = {
version: 'v2',
@@ -90,7 +90,7 @@ describe('real Loader composition', () => {
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
const server = loaded.httpServer
const server = loaded.webServer
const port = server.port
// Real assets with their MIME types; a live rebuild is served on the next read.

View File

@@ -21,7 +21,7 @@
"path": "../webserver"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

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/plugin-inventory/README.md
README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de
README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea
README.md: a91c02df5e7a57db89c9a63af6c4355573b843da
README.zh.md: 261db0b92cf025606b7fc18c5754d54f8cd34e48

View File

@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
Read-only Host projection of the current Cordis Loader tree. `PluginInventoryGateway` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.

View File

@@ -2,9 +2,9 @@
[English](README.md) | 中文
当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryGateway` 注册 `pluginInventory` 服务,并发布一个由 Typert 生成的直接 Remote`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
阶段为 `pending``loading``active``failed``unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`TypeRT 生成由 `./typert``./remote` 导出的 Host 和 Client Remote 产物。
阶段为 `pending``loading``active``failed``unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`Typert 生成由 `./typert``./remote` 导出的 Host 和 Client Remote 产物。
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。

View File

@@ -55,14 +55,14 @@
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -2,8 +2,8 @@
import type { Context, FiberState } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime.
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
// Typert-generated ./typert and ./remote artifacts import Zod at runtime.
import type {} from 'zod'
import type {
PluginEntryId,
@@ -40,7 +40,7 @@ const FIBER_PHASE = {
} as const satisfies Record<FiberState, PluginFiberPhase>
/** Remote-only service exposing the Loader's current non-group entry state. */
export class PluginInventoryService extends GatewayService {
export class PluginInventoryGateway extends TypertRemoteService {
static inject = ['loader']
constructor(ctx: Context) {
@@ -69,4 +69,4 @@ export class PluginInventoryService extends GatewayService {
}
}
export default PluginInventoryService
export default PluginInventoryGateway

View File

@@ -1,12 +1,12 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import InvariantService from '@deepseek-ai/dsh-invariants'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as PluginInventoryInvariant from '../src/invariant.ts'
describe('plugin-inventory invariant companion', () => {
it('registers the package-owned empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
const fiber = ctx.plugin(PluginInventoryInvariant)
await expect(fiber.await()).resolves.toBeDefined()
await fiber.dispose()

View File

@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
import PluginInventoryService from '../src/index.ts'
import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
import PluginInventoryGateway from '../src/index.ts'
const contexts: Context[] = []
@@ -18,22 +18,22 @@ const pendingPlugin: Plugin.Object = {
async function harness(): Promise<{
ctx: Context
inventory: PluginInventoryService
inventory: PluginInventoryGateway
}> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Loader)
ctx.loader.builtins.active = activePlugin
ctx.loader.builtins.pending = pendingPlugin
await ctx.plugin(PluginInventoryService)
const inventory = ctx.get('pluginInventory') as PluginInventoryService
await ctx.plugin(PluginInventoryGateway)
const inventory = ctx.get('pluginInventory') as PluginInventoryGateway
return { ctx, inventory }
}
describe('PluginInventoryService', () => {
describe('PluginInventoryGateway', () => {
it('publishes one direct list method under the pluginInventory namespace', async () => {
const { inventory } = await harness()
expect(inventory.typertGateway).toMatchObject({
expect(inventory.typertRemote).toMatchObject({
serviceKey: 'pluginInventory',
namespace: 'pluginInventory',
})

View File

@@ -18,10 +18,10 @@
"path": "../../util/brand"
},
{
"path": "../../typert/type-meta"
"path": "../../typert/protocol"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

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: c41001fba3a69bfd7c00550d0be602e3fc2e0474
README.zh.md: d66a6666887647db4f6bc60d1c535336f0ac4cc1
README.md: 7927c791a809ad272edce1310b3c50bbe30720a5
README.zh.md: 82252adb3945f22c83abddbd3bbdd11556383add

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
Web HTTP and upgrade-route registration plugin (default-exported `WebServer`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.webServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-host-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.

View File

@@ -2,7 +2,7 @@
[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)` 注册一个 handler处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
Web HTTP 与 upgrade route 注册插件(默认导出 `WebServer`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-host-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route再匹配最长前缀最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route插件 bundle 与 HMR热模块替换事件流是 moduleshmr 插件的 routedist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认安全姿态)和 `0.0.0.0`有意向网络开放。该服务器只服务浏览器Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch。该包从不打印内容URL 行属于 shell。

View File

@@ -1,6 +1,6 @@
/**
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
* server plus the `httpServer` service (HTTP and upgrade route registries,
* server plus the `webServer` 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 hook.
@@ -17,7 +17,7 @@ import z from '@deepseek-ai/schemastery'
declare module '@deepseek-ai/cordis' {
interface Context {
httpServer: HttpServerService
webServer: WebServer
}
}
@@ -56,7 +56,7 @@ export interface Config {
* during startup with 404 until its owner registers. A listen failure rejects
* initialization, and the boot process reports the failed fiber.
*/
export class HttpServerService extends Service {
export class WebServer extends Service {
static Config: z<Config> = z.object({
host: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')]).required(),
port: z.natural().max(65535).required(),
@@ -72,7 +72,7 @@ export class HttpServerService extends Service {
private listenedPort!: number
constructor(ctx: Context, private config: Config) {
super(ctx, 'httpServer')
super(ctx, 'webServer')
}
/** The listening port (the OS-assigned value when config.port is 0). */
@@ -235,7 +235,7 @@ export class HttpServerService extends Service {
socket.destroy()
}))
await Promise.all([serverClosed, ...upgradedClosed])
}, 'httpServer.listen')
}, 'webServer.listen')
}
/** Longest-prefix-wins over the prefix table after an exact-table miss. */
@@ -263,4 +263,4 @@ export class HttpServerService extends Service {
}
}
export default HttpServerService
export default WebServer

View File

@@ -25,7 +25,7 @@ export const inject = ['invariants']
*/
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const server = ctx.get('httpServer') as
const server = ctx.get('webServer') as
| {
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
registerUpgrade(route: { path: string; handler: () => void }): () => void
@@ -44,7 +44,7 @@ const install: InvariantInstaller = (ctx, fail) => {
server.registerUpgrade(upgradeProbe)()
server.registerUpgrade(upgradeProbe)()
} catch {
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
fail('webServer route disposer left a route registered — route tables and fiber lifecycles diverged')
}
}, { global: true })
}

View File

@@ -96,7 +96,7 @@ describe('real Loader composition', () => {
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
const server = loaded.httpServer
const server = loaded.webServer
expect(server).toBeInstanceOf(HttpServer)
const port = server.port
expect(port).toBeGreaterThan(0)
@@ -202,7 +202,7 @@ describe('real Loader composition', () => {
it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => {
const first = await loadComposition()
const takenPort = first.httpServer.port
const takenPort = first.webServer.port
const firstRoot = root
root = undefined // keep the first composition's files until the end

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/schemastery"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}