Merge branch 'master' into codex/allow-rewrite-pushed-pr-history

This commit is contained in:
Tianyi Cui
2026-07-29 14:29:21 +08:00
committed by GitHub
648 changed files with 22302 additions and 3272 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812
2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda
2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a

View File

@@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising:
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## Consequences

View File

@@ -29,7 +29,7 @@ Status: implemented
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
格式版本控制header 携带一个 `version``load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
格式版本控制header 携带一个 `version``load` 拒绝任何非当前版本预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
## 后果

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0
2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee

View File

@@ -0,0 +1,31 @@
# Agent Note: One carrier-level browser-trust boundary for the whole /api surface
Status: implemented
English | [中文](2026-07-28-api-browser-trust-boundary.zh.md)
## Problem
The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the upcoming in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse.
## Decision
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs:
- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover.
## Alternatives considered
- **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for.
- **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler.
- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design.
## Consequences
- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget.
- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused.
- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header).
- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit.

View File

@@ -0,0 +1,31 @@
# Agent Note整个 /api 面共用一道载体级浏览器信任边界
状态:已实现
[English](2026-07-28-api-browser-trust-boundary.md) | 中文
## 问题
Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以"同源"身份直连 socketCORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正要命的方法都在裸奔。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。
## 决策
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR
- **媒体类型栅栏dsh-host-apiproxy**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
- **权威栅栏dsh-client-connection`src/api-request-trust.ts`**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取EventSource、图片、导航——这些头只发给可信目标既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求可能是被重绑页面发起且响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后socket 地址提供不了头部栅栏覆盖不到的任何东西。
## 曾考虑的替代方案
- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。
- **CORS 头 + 省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。
- **现在就上认证令牌。** 在本变更中否决:令牌的签发/存储/轮换是真实的产品面;栅栏今天就能封死浏览器代理人漏洞,无需预先决定认证设计。
## 后果
- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
- 非回环部署的服务权威必须获得信任否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL并提供 `dsh web --trusted-host` 声明具名权威CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。
- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
- 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md
2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64
2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1

View File

@@ -0,0 +1,63 @@
# Agent Note: Consolidated TUI presentation and navigation
Status: implemented
English | [中文](2026-07-28-consolidated-tui-presentation.zh.md)
## Problem
The terminal UI accumulated independent presentation rules that interacted poorly: palette roles aliased one another or inverted emphasis on light terminals; tool-card framing, output, and exit markers repeated or competed; injected context was parsed as XML and could not fold reliably; and `/resume` excluded sessions outside the current workspace even when the launcher could reach them. Each symptom appeared local, but the durable decision is one terminal-reading model: a small inspectable palette, status-led cards with recessed bodies, content-independent transcript folding, and workspace-aware navigation.
## Decision
### Palette
`paletteSpec(scheme)` is the single table of SGR codes, close codes, and purposes. `createPalette` derives every wrapper from it and `/palette` prints the same table in the running terminal. Components do not emit their own SGR sequences except for the fixed startup brand gradient. Every close resets every SGR group its open sets.
Duplicate roles are merged: `muted` into `dim`, `added` into `success`, `removed` into `error`, and the unused second accent is removed. `dim` uses `2;39` and closes with `22;39` on both schemes so recessed text stays relative to the terminal foreground rather than becoming a fixed heavy gray on light backgrounds. Colors and attributes are branded separately in TypeScript, allowing attribute/color composition while rejecting nested colors whose reset would discard the outer color.
### Tool cards
A tool card has one colored `Tool / <name>` status header over one dim body. Presenter titles, terminal commands and cwd rows, output, XML text, and fold markers use that body tone. Diff colors remain because red and green carry meaning, and signal markers remain errors.
`renderUnknownXml` receives an explicit body styler for unknown tool results. Terminal presenters parse and remove the model-facing final exit or signal marker before returning `TerminalResultView.output`; the TUI renders the structured status once as its own pill. Truncation, timeout, and sandbox lines remain in the body because the pill does not represent them.
### Injected context and folding
Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `<system-reminder>` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters.
`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic.
### Cross-workspace resume
The resume picker summarizes all records and owns a current-workspace/all-workspaces scope toggled with Tab. It defaults to the current workspace, adds workspace labels only in the broader scope, and refuses records without a cwd because there is no directory to enter.
`TuiResumeHost.handoff` receives the selected `SessionId` and the cwd re-read during preflight. The CLI changes directory before disposing the current app, so an unreachable directory fails while the terminal can still recover; `execve` then inherits the selected workspace. The launcher also supplies the exit message rather than asking the TUI to reconstruct launcher syntax.
## Alternatives considered
**Keep separate notes and local fixes for each visual symptom.** Rejected: the decisions share one reading hierarchy and repeatedly superseded each other. One owner makes the final palette, card, context, and navigation rules clear without requiring readers to reconstruct chronology.
**Keep aliases and enforce presentation by convention.** Rejected: aliases imply distinctions that do not exist, and nested color resets or incomplete SGR closes fail silently. A single table plus types makes the contract inspectable and mechanically checked.
**Retain framing/output color splits inside tool cards.** Rejected: real cards mixed default foreground, cyan commands, dim cwd, unstyled XML, and dim output. The status header already provides the scan anchor; one recessed body removes noise. Diff colors are the narrow semantic exception.
**Parse or repair injected context as XML.** Rejected: reminder frames are prompting conventions around arbitrary prose containing raw ampersands, comparisons, and placeholder angle brackets. Repairing or escaping it would either guess structure or alter model-visible text.
**Hide context cards with tool cards.** Rejected: context carries injected instructions, not recoverable execution detail. The hidden phase therefore removes only tool traffic.
**Keep resume restricted to one workspace or infer cwd after boot.** Rejected: the restriction forces manual relaunch, while restored header cwd does not control filesystem and shell resolution. The target directory must cross the host seam before process replacement.
**Drop the TUI exit pill or remove model-facing exit markers.** Rejected: the pill is the scannable UI status, while the text marker is the model's status signal. The presenter consumes the marker when constructing the structured view so both audiences receive one representation.
## Consequences
The transcript reads as colored status headers over recessed detail, context presentation is stable for arbitrary prose, and one shortcut controls transcript density. The public `TuiTheme.muted` role is removed; extensions use `dim`. The palette and `renderUnknownXml` contracts are stricter, adding small compile-time friction in exchange for preventing silent style loss.
Cross-workspace resume can move every path-resolving tool to another directory. A missing or inaccessible cwd prevents handoff. The broader picker also makes concurrent access to a shared session store easier to reach; cross-process session locking remains separate work.
The terminal presenter still treats a final output line exactly matching its exit-marker grammar as structured status, so a command that intentionally prints such a line can lose it from the card body. This residual is documented by `dsh-tool-bash`.
## Testing
TUI unit and keyless terminal snapshots cover palette enumeration, light/dark roles, legal and illegal style composition, uniformly dim card bodies, semantic diff colors, marker-free terminal output with one exit pill, prose-preserving context frames, content-independent folding, the three-state Ctrl+O cycle, model filtering, and both resume scopes. CLI handoff tests cover passing the re-read cwd and rejecting directory-entry failure before teardown. Tool-bash tests pin result-marker emission, parse, and stripping as one round trip.

View File

@@ -0,0 +1,63 @@
# Agent Note: 统一的 TUI 呈现与导航
Status: implemented
[English](2026-07-28-consolidated-tui-presentation.md) | 中文
## Problem
终端 UI 逐步积累了多套彼此干扰的呈现规则:调色板角色互为别名,或在浅色终端中颠倒强调层级;工具卡片的框架、输出和退出标记重复或争夺注意力;注入上下文被当作 XML 解析,无法可靠折叠;`/resume` 即使能通过启动器访问其他工作区,也会排除不属于当前工作区的会话。每个症状看似局部,但持久决策只有一个终端阅读模型:精简且可检查的调色板、以状态为首且正文内收的卡片、与内容无关的记录折叠,以及感知工作区的导航。
## Decision
### 调色板
`paletteSpec(scheme)` 是 SGR 开始码、结束码和用途的唯一表。`createPalette` 从该表派生所有包装器,`/palette` 在运行中的终端打印同一张表。除固定的启动品牌渐变外,组件不自行发出 SGR 序列。每个结束码都重置对应开始码设置的所有 SGR 组。
重复角色被合并:`muted` 并入 `dim``added` 并入 `success``removed` 并入 `error`,未使用的第二强调色被移除。`dim` 在两种配色方案中都使用 `2;39`,并以 `22;39` 结束使内收文本相对于终端前景色变暗而不会在浅色背景上变成固定的深灰色。TypeScript 分别标记颜色和属性,允许属性与颜色组合,同时拒绝会因重置而丢失外层颜色的嵌套颜色。
### 工具卡片
工具卡片由一行带颜色的 `Tool / <name>` 状态标题和一块统一的 dim 正文组成。呈现器标题、终端命令及 cwd 行、输出、XML 文本和折叠标记都使用正文色调。差异颜色继续保留,因为红绿承载语义;信号标记也继续作为错误显示。
`renderUnknownXml` 对未知工具结果显式接收正文样式器。终端呈现器在返回 `TerminalResultView.output` 前解析并移除面向模型的末尾退出或信号标记TUI 只把结构化状态呈现一次。截断、超时和沙箱信息继续留在正文中,因为状态标记不表达这些事实。
### 注入上下文与折叠
注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `<system-reminder>` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。
`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。
### 跨工作区恢复
恢复选择器汇总所有记录,并维护可用 Tab 切换的当前工作区/所有工作区范围。默认范围是当前工作区;只有更宽范围才显示工作区标签。没有 cwd 的记录会被拒绝,因为没有可进入的目录。
`TuiResumeHost.handoff` 接收选中的 `SessionId` 和预检时重新读取的 cwd。CLI 在释放当前应用前切换目录,因此无法访问的目录会在终端仍可恢复时失败;随后 `execve` 继承所选工作区。退出提示也由启动器提供,而不是让 TUI 反推启动器命令语法。
## Alternatives considered
**为每个视觉症状保留独立 Agent Note 和局部修复。** 否决:这些决策共享同一阅读层级,而且彼此多次取代。由一份记录统一拥有最终的调色板、卡片、上下文和导航规则,读者无需重建变更顺序。
**保留别名,并依靠约定执行呈现规则。** 否决:别名暗示并不存在的差异;嵌套颜色重置或不完整的 SGR 结束会静默失败。单一表格加类型约束使契约可检查且可机械验证。
**保留工具卡片内部的框架/输出颜色分层。** 否决真实卡片会混用默认前景、青色命令、dim cwd、无样式 XML 和 dim 输出。状态标题已经提供扫描锚点;统一内收正文能消除噪声。差异颜色是狭窄的语义例外。
**把注入上下文继续解析或修复成 XML。** 否决:提醒框架只是包裹任意普通文本的提示约定,其中会包含原始 `&`、比较表达式和尖括号占位符。修复或转义要么猜测结构,要么改变模型可见文本。
**随工具卡片一起隐藏上下文卡片。** 否决:上下文承载注入指令,不是可恢复的执行细节。因此隐藏阶段只移除工具流量。
**把恢复限制在一个工作区,或在启动后推断 cwd。** 否决:前者迫使用户手动重启;后者恢复的会话头 cwd 并不控制文件系统和 shell 的路径解析。目标目录必须在进程替换前跨过主机接口。
**移除 TUI 退出状态标记,或移除面向模型的退出标记。** 否决:前者是便于扫描的 UI 状态,后者是模型的状态信号。呈现器在构造结构化视图时消费文本标记,使两类受众各看到一种表示。
## Consequences
记录现在表现为带颜色的状态标题和内收细节;上下文对任意普通文本都稳定呈现;一个快捷键控制记录密度。公共 `TuiTheme.muted` 角色被移除,扩展改用 `dim`。调色板和 `renderUnknownXml` 契约更严格,以少量编译期摩擦换取对静默样式丢失的防护。
跨工作区恢复会把所有依赖路径解析的工具移动到另一个目录。cwd 缺失或不可访问时不能交接。更宽的选择范围也使共享会话存储的并发访问更容易触达;跨进程会话锁仍是独立后续工作。
终端呈现器仍会把与退出标记语法完全一致的最后一行输出视为结构化状态,因此命令有意打印这种行时,卡片正文可能丢失该行。`dsh-tool-bash` 已记录这一残余限制。
## Testing
TUI 单元测试和无密钥终端快照覆盖调色板枚举、浅色/深色角色、合法与非法样式组合、统一 dim 卡片正文、保留语义的差异颜色、仅有一个退出状态且正文无标记、普通文本上下文框架、与内容无关的折叠、Ctrl+O 三态循环、模型过滤和两种恢复范围。CLI 交接测试覆盖传递重新读取的 cwd并在释放前拒绝目录切换失败。tool-bash 测试把结果标记的生成、解析和移除固定为同一轮往返契约。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38
2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464

View File

@@ -0,0 +1,39 @@
# Agent Note: A capability-discriminated directory-picker seam for the web-GUI host
Status: implemented
English | [中文](2026-07-28-directory-picker-capability-seam.zh.md)
## Problem
The web GUI's "Open local folder" flow was hardwired to one interaction: `host.pickDirectory` invoked a native OS chooser compiled into `dsh-host-apiproxy` (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma `Harness` 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance.
## Decision
A three-package capability seam in `packages/host/``directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape.
**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read.
Placement and policy rulings folded into this decision:
- **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home.
- **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib.
- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs.
## Alternatives considered
- **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam.
- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires.
## Consequences
- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative.
- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests.
- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.

View File

@@ -0,0 +1,39 @@
# Agent Noteweb GUI 宿主的能力可辨识目录选择 seam
状态:已实现
[English](2026-07-28-directory-picker-capability-seam.md) | 中文
## 问题
web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器(私有模块,仅测试注入缝)。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器Figma `Harness` 802-56979需要列举创建原语那是**另一种交互契约**,不是同一契约的另一种实现。想换交互只能改网关源码,违背仓库"一切皆插件"的立场。
## 决策
`packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native``directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**`{ kind: 'native', pick(signal) }``{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`;之所以是两个 key是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`承载整个交换ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。
并入本决策的位置与策略裁决:
- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型会话的存储栈policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实home 锚定、隐藏约定也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。
- **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)``homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager``files-and-folders`、Syncfusion 的 provider是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`健康度比例失当。browse 后端是标准库上的薄适配。
- **隐藏条目:返回并打标。** 宿主标注 `hidden`POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await打开、每次读取、每次符号链接探测都与信号赛跑中止路径放弃而非等待 closeNode 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
- **native 后端保留。** 插件化正是目的:多方都能提供该 seamElectron 壳可以经自己的对话框 API 提供 `native` 交互。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。
## 曾考虑的替代方案
- **给 `ctx.fs` 增加浏览方法。** 否决上述权限域耦合且面向展示的列举契约hidden 标志、面包屑、home 锚点)不属于存储 seam。
- **统一方法集的 seam`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
- **apiproxy 里直接调标准库(不建 seam。** 否决:换装点仍是改网关源码,失去 fixture测试后端与促成这项工作的插件教义相悖。
- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。
## 后果
- `cordis.yml` 决定交互形态;`apps/cli``-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI`-native` 仍是宿主屏幕方案。
- 协议新增 `host.listDirectory``host.createDirectory` 与四个错误码connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md
2026-07-28-dsh-native-typescript-source-launch.md: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b
2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0

View File

@@ -0,0 +1,45 @@
# Agent Note: Native TypeScript source launch for dsh
Status: implemented
English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
## Problem
The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch.
Node's transform also does not perform type analysis. A type imported through an ordinary value import remains a runtime ESM request, and TypeScript's `export =` becomes a CommonJS assignment rather than an ESM default export. The source graph therefore has to use explicit type-only imports and native ESM exports; a resolve hook cannot repair incompatible source syntax.
Cordis configuration introduces a separate resolution boundary. Bare plugins in `cordis.yml` do not pass through TypeScript import analysis, so their resolver manifest may omit the required dependencies. The Cordis Loader logs plugin import errors and leaves an entry without a fiber, but does not fail startup itself; a typo in the configuration can therefore produce an incomplete application with exit code 0.
## Decision
The `dsh` TUI, Web, and headless source launches use `node --experimental-transform-types`; Node performs TypeScript transformation without loading `tsx` or esbuild. `bin/dsh`, the root-level `dsh`/TUI/Web demos, and Code Mode TUI enter the same `apps/cli/src/bin.ts` launch chain. Test and E2E launchers retain their existing strategies, and the built `lib/bin.js` continues to run under ordinary Node.
`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency.
Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution.
`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule.
After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior.
Node-compatible TypeScript is part of this source-launch contract. Vendored Cordis, Loader, Include, HMR, and Schemastery mark erased imports with `import type`. Schemastery uses a native ESM default export and declares `type: module`; its `.mjs` and `.cjs` build outputs retain the existing ESM-default and callable-`require()` behavior. These divergences are recorded in `vendor/README.md`; no runtime behavior is added to the vendored frameworks.
## Alternatives considered
**Continue using `tsx`.** Rejected because `tsx`/esbuild would continue to own TypeScript transformation, so this launch chain could not prove that Node's native transformation works.
**Load the built `lib/` through package exports from the source entry point.** Rejected because this would mix the source plane with the artifact plane; a zero-build development launch could read stale artifacts or fail outright.
**Apply the root tsconfig `paths` unconditionally.** Rejected because this would allow undeclared cross-package imports and Cordis plugins to keep resolving, hiding mismatches between the manifest and the actual runtime graph.
**Transform imports inside the custom loader.** Rejected because type-aware source rewriting would reintroduce a compiler-style transform and make the loader, rather than Node, responsible for TypeScript execution. Making the checked-in source Node-compatible keeps the launch boundary explicit.
## Consequences
- TUI/headless retain a zero-build source loop, while Web still builds its frontend artifacts before starting the CLI source entry. TypeScript syntax passes only through Node's native transform; the URL-only loader uses the checkout's root development dependencies and adds no CLI runtime dependency.
- Workspace package imports and Cordis configuration dependencies must both be declared explicitly in the resolver manifest; the static gate prevents configuration from landing before its dependencies, while extra dependencies are not errors.
- Plugin import failures no longer leave an incomplete application with exit code 0; the final error identifies both the Cordis startup failure and the specific plugin names, while the Loader's original error remains earlier in the logs.
- Vendored source in the CLI graph must remain compatible with Node's transform-types module semantics; the local-modification log makes the upstream sync obligation explicit.
- CI's `lib` mode, test/E2E launchers, and other example launchers retain their existing strategies; this native source loader covers only the `dsh` CLI application chain.

View File

@@ -0,0 +1,45 @@
# Agent Note: dsh 原生 TypeScript 源码启动
Status: implemented
[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
## 问题
`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。
Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此源码图必须显式使用仅类型导入和原生 ESM 导出resolve hook 无法修复不兼容的源码语法。
Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry但不会让启动本身失败配置中的拼写错误因此可能得到退出码为 0 的残缺应用。
## 决策
`dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。
`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json``TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI`apps/cli` 也不会把 `typescript` 声明为运行时依赖。
只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent此时 resolver 会向上查找声明该插件的 workspace manifest。因此`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。
`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies`manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。
Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。
Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR热模块替换和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs``.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。
## 曾考虑的替代方案
**继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。
**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane零构建开发启动可能读取陈旧产物或直接失败。
**无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。
**在自定义 loader 内转换 import。** 不采用,因为感知类型的源码改写会重新引入编译器式转换,并让 loader 而非 Node 负责执行 TypeScript。使签入仓库的源码兼容 Node可以让启动边界保持显式。
## 后果
- TUI无头界面保留零构建源码回路Web 仍会在启动 CLI 源码入口前构建前端产物。TypeScript 语法只经过 Node 原生转换;仅处理 URL 的 loader 使用 checkout 根目录的开发依赖,不增加 CLI 运行时依赖。
- workspace package import 和 Cordis 配置依赖都必须在解析方 manifest 中明确声明;静态门禁防止配置先于依赖落地,额外依赖不构成错误。
- 插件 import 失败不再留下退出码为 0 的残缺应用;最终错误同时说明 Cordis 启动失败及具体插件名Loader 的原始错误仍会保留在更早的日志中。
- CLI 源码图中的 vendor 源码必须与 Node 的 transform-types 模块语义兼容;本地修改记录明确了上游同步义务。
- CI 的 `lib` 模式、测试e2e 启动器和其他示例启动器保留各自现有策略;该原生源码 loader 只覆盖 `dsh` CLI 应用链路。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md
2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41
2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3

View File

@@ -0,0 +1,38 @@
# Agent Note: Load sessions persisted before message identity
Status: implemented
English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md)
## Problem
The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`.
Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events.
## Decision
`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message:<session-id>:<event-seq>` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant.
The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths.
The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction.
## Alternatives considered
**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation.
**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system.
**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends.
## Consequences
Pre-identity JSONL and SQLite sessions resume with their original message content, sources, provider provenance, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen.
This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity.
## Related
- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract.
- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary.

View File

@@ -0,0 +1,38 @@
# Agent Note: 加载消息标识机制引入前持久化的会话
Status: implemented
[English](2026-07-28-load-pre-identity-session-messages.md) | 中文
## 问题
带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering中途引导事件直接携带 `content`/`source`assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`
消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。
## 决策
`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId``legacy-message:<session-id>:<event-seq>`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id从而保持当前仅改写内容的不变量。
同一项规范化也用于 `load``inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR热模块替换前缀接管。因此前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。
这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。
## 考虑过的替代方案
**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。
**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。
**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。
## 后果
消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。
这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外必须在持久化边界提供另一套完整且无歧义的映射当前数据若格式错误系统仍会拒绝而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。
## 相关
- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。
- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4
2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2

View File

@@ -0,0 +1,27 @@
# Agent Note: Isolate pnpm setup per GitHub Actions runner
Status: implemented
English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md)
## Problem
`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression.
## Decision
Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
## Alternatives considered
**Serialize failover jobs.** Rejected because it discards the six-runner pool's intended parallelism and turns an action-local directory collision into queueing across otherwise independent jobs.
**Assign a separate Unix user to every runner service.** This would also separate `HOME`, but it moves the invariant into external VM provisioning and complicates ownership of the deliberately shared persistent pnpm store. The workflow already receives a runner-private temporary directory.
**Retry failed setup steps.** Rejected because retries only reduce the observed collision rate; another concurrent setup can remove the same shared directory again.
## Consequences
pnpm's executable installation is ephemeral and isolated per runner, while package downloads still use the configured persistent or cached store. Hosted jobs use the same explicit destination without changing cache policy. The workflow carries three extra configuration lines per setup step, and the regression test must be updated only if pnpm provisioning intentionally moves to a different isolation mechanism.

View File

@@ -0,0 +1,27 @@
# Agent Note: 按 GitHub Actions runner 隔离 pnpm 设置
Status: implemented
[English](2026-07-29-pnpm-setup-runner-isolation.md) | 中文
## 问题
`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。
## 决策
[主 CI 工作流](../../../../.github/workflows/ci.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 提供机制决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md)。
[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
## 曾考虑的替代方案
**串行执行故障切换作业。** 否决:这会牺牲由六个 runner 组成的池所具备的预期并行能力,并把 action 内部的目录冲突变成原本相互独立作业之间的队列等待。
**为每个 runner 服务分配独立的 Unix 用户。** 这同样能够隔离 `HOME`,但会把该不变量转移到外部 VM 配置中,并使刻意共享的持久 pnpm store 的所有权变得复杂。工作流已经获得 runner 专属临时目录。
**重试失败的设置步骤。** 否决:重试只能降低观测到的冲突发生率;另一个并发设置过程仍可能再次删除同一个共享目录。
## 后果
pnpm 可执行文件采用临时安装,并按 runner 隔离;包下载仍使用已配置的持久或缓存 store。托管作业使用相同的显式目标目录不改变缓存政策。工作流中的每个设置步骤因此增加三行配置只有在 pnpm 提供机制有意迁移到另一种隔离机制时,才需要更新回归测试。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6
2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md
2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31
2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10

View File

@@ -123,7 +123,7 @@ Costs and accepted limits:
- **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt.
- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two.
- **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant.
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred).
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred).
- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair.
- **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state.
- **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam.

View File

@@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
- **谁决定一次调用是否需要 ask** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。
- **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
- **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 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 .agents/notes/implemented/feature/2026-07-06-sandbox.md
2026-07-06-sandbox.md: c3c61ed4539bcbca359f84f3dcc020e0bd41ae79
2026-07-06-sandbox.zh.md: 39bf92aa20c697d2ad5f4c0926caeff0a0b60a1d
2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e
2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a

View File

@@ -89,10 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode —
#### Per-session modes: the session log as the store
```
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
```
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere.
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)).
**One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages):

View File

@@ -89,10 +89,10 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes
#### 按会话模式:会话日志即存储
```
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
```
默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储
默认值是组合配置(`cordis.yml`)——运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录该会话日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md)
**每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-20-dsh-cli-personal-config.md: 9525aa811d792a918f03a52c21bc273e92fb8be7
2026-07-20-dsh-cli-personal-config.zh.md: f21d4b1f22b3a3807b6b4155969282343f6048f5
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95
2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7

View File

@@ -12,7 +12,7 @@ A developer's own preferences — which provider and model the TUI uses, persona
Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443):
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry.
**Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim:

View File

@@ -12,7 +12,7 @@ Status: implemented
两个耦合的部分,与 `dsh web` PR#443)提出的 `apps/` 装配层对齐:
**`dsh` CLI`apps/cli`npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web``-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout用仓库的 tsx **从源码**运行该 bin因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
**`dsh` CLI`apps/cli`npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web``-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
2026-07-22-docked-web-goal-bar.md: 52a7d223ce3522c5ba977b1126dcd63bd2f6366f
2026-07-22-docked-web-goal-bar.zh.md: e4842a03ccb8a29b35c7af0c03c51b1324b6ab36

View File

@@ -0,0 +1,39 @@
# Agent Note: Docked web goal bar
Status: implemented
English | [中文](2026-07-22-docked-web-goal-bar.zh.md)
## Problem
The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal.
## Decision
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped.
The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens.
## Testing
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
## Alternatives considered
- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card.
- **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state.
- **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface.
- **Carry the full verb set (`onPause`/`onComplete`) in `GoalBarActions`** — rejected as speculative generality: no consumer calls them, so the interface carries only the rendered verbs.
## Consequences
- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and edit/clear (plus resume when paused) — the browser client's first goal surface.
- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; pause/complete remain available to other surfaces (`/goal`, model tools).
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.

View File

@@ -0,0 +1,39 @@
# Agent Note: 停靠式 Web 目标条
Status: implemented
[English](2026-07-22-docked-web-goal-bar.md) | 中文
## 问题
Web UI 此前没有任何目标相关的界面目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性因此它的指示器停靠在消息输入框正上方呈现为一条圆角顶部的横条收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词"Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。
## 决策
`GoalBar``packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
可见性决定标签和操作active 状态显示 "Ongoing Goal" 并提供编辑清除paused 状态显示 "Paused Goal"并增加一个恢复图标按钮blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上不在横条里。铅笔图标把横条切换为内联编辑表单预填当前目标内容Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref因此 UI 不传 ref。
运行时会话获得了横条(以及未来 UI所需的目标表面`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。
横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。
## 测试
`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为加载中无目标已完成时不渲染active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消并在目标身份变化时重置paused 横条触发恢复blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿并且编辑恢复清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions``ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
## 考虑过的替代方案
- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。
- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。
- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。
- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。
## 后果
- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。
- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。
- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438
2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145

View File

@@ -0,0 +1,33 @@
# Agent Note: Web UI permission presets and approval answering
Status: implemented
English | [中文](2026-07-23-web-permission-and-approval.zh.md)
## Problem
The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/requested`/`approval/resolved` mux frames, `POST /api/respond` with `ApprovalResponsePayload`, client-side `pendingBuffers` — but the host `respond` was a stub, no answerer bridged `ctx.approval` to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable.
## Decision
The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`).
`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`.
The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy.
Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session.
## Alternatives considered
**Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature.
**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay.
**Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window.
**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead.
## Consequences
Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser.

View File

@@ -0,0 +1,33 @@
# Agent Note: Web UI 权限预设与审批应答
Status: implemented
[English](2026-07-23-web-permission-and-approval.md) | 中文
## 问题
Web 承载层启动的是一个不受限的 agent智能体`bootHost` 组合了 `dsh-bash-local``dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload``POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择PendingCard 把审批渲染成可见却无法应答的样子。
## 决策
Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local``dsh-sandbox-policy``dsh-bash-sandbox``dsh-fs-sandbox``dsh-user-approval``dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write``approvalPolicy`,默认 `ask`)。
`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall瀑布式事件应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`ask 的中断信号会以 `cancelled` 撤回该问题。
权限选择依托两个新的一元 RPC`session.permissions``session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO沿用 ACP bridge 的先例每个协议拥有自己的呈现形状。无权限的组合提供空的选择项client 隐藏该控件。空闲期的切换以后写胜出last-write-wins的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。
在 client 侧,`Session` 新增了 `permissions``setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBarui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码wire encoding广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态且其优先级高于表示运行中的圆环manager 跟踪每个会话尚未解决的 approvalId对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture测试前置数据与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。
## 曾考虑的替代方案
**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema是它自成一体的方言一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。
**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。
**仅在存在 mux 订阅者时才注册应答者。** 不予采纳pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。
**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。
## 后果
Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值无需触动装配。问题应答已通过同一注册表模式单独交付ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca
2026-07-25-subagent-policy-inheritance.zh.md: 7a09fd972f53b46655df93ddf9625455f2077460

View File

@@ -0,0 +1,36 @@
# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides
Status: implemented
English | [中文](2026-07-25-subagent-policy-inheritance.zh.md)
## Problem
Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior.
## Decision
The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants.
Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism.
Ordinary session appends validate the inherited events before publication, and persistence captures the complete unpublished log when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch.
### What a blocked child experiences
A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt.
## Alternatives considered
- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Unpublished setup events have the required ordering and reuse the existing durable store.
- **Combining new policy facts with constructor history** — rejected: `Session.firstLiveSeq` classifies the complete constructor seed as replayed history, so telemetry would skip child-only facts. Unpublished setup keeps history and new facts on their existing sides of that boundary without another session option.
- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication.
- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment.
- **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening.
- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md).
## Consequences
- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal.
- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed.
- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches.

View File

@@ -0,0 +1,36 @@
# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动
Status: implemented
[English](2026-07-25-subagent-policy-inheritance.md) | 中文
## 问题
沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent智能体过去会回退到部署默认值fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。
## 决策
共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)``approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。
每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode``approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。
普通的会话追加会在发布前校验继承事件,持久化层则在会话公布时捕获完整的未发布日志。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。
### 被拦住的子 agent 会经历什么
受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent因此升级请求会失败关闭由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。
## 考虑过的替代方案
- **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。未发布设置阶段的事件具备所需顺序,并复用现有持久化存储。
- **将新策略事实与构造历史合并**:不予采纳。`Session.firstLiveSeq` 会把完整的构造种子归类为回放历史,因此遥测会跳过仅属于子 agent 的事实。未发布设置让历史与新事实留在该边界各自原有的一侧,无需再增加会话选项。
- **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。
- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。
- **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。
- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。
## 后果
- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。
- 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。
- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。

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 .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
2026-06-16-pnpm-over-yarn.md: 918da1d056dfec73f78d849e08ad06d397e654c0
2026-06-16-pnpm-over-yarn.zh.md: 4fbb357f689b4a960d0662cbefa5bec30f79bcc1
2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1
2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3

View File

@@ -8,7 +8,7 @@ English | [中文](2026-06-16-pnpm-over-yarn.zh.md)
The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers.
The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); dev/test/demo all run **unbuilt** via tsx, so the package manager only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); development, tests, and source-mode demos run through their declared TypeScript launchers, while artifact checks build explicitly. The package manager therefore only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
## Decision

View File

@@ -8,7 +8,7 @@ Status: implemented
本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent智能体构建、偶尔有人类贡献者阅读的仓库而言「大多数工具和人所期望的包管理器」具有实际价值更少的意外、更成熟的故障路径、更多可直接复用的解答。
切换成本目前处于最低点。本仓库尚无任何包package发布每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到a解析并链接 `node_modules`b运行 workspace 脚本c强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs``@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
切换成本目前处于最低点。本仓库尚无任何包package发布每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此包管理器只需做到a解析并链接 `node_modules`b运行 workspace 脚本c强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs``@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
## 决策

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-06-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3
2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md
2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf
2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484

View File

@@ -62,7 +62,7 @@ pnpm run clean:
tsx scripts/clean.ts
```
`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
The source-mode demos run through their declared TypeScript launchers and the root paths map. The `dsh` TUI chain uses Node's native transform plus its app-owned paths loader, the Web demo builds its required artifacts before entering that same CLI source chain, and the other source demos continue to use tsx.
## Alternatives considered
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
Build responsibilities are clearer:
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`.
- Each module under `packages/<group>/<pkg>` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`.
- The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
- `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output.
- `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files.

View File

@@ -62,7 +62,7 @@ pnpm run clean:
tsx scripts/clean.ts
```
`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤
源码模式 demo 通过各自声明的 TypeScript 启动器和根路径映射运行`dsh` TUI 链使用 Node 原生转换及应用自有的路径 loaderWeb demo 在进入同一条 CLI 源码链路前先构建所需产物,其他源码 demo 继续使用 tsx
## 曾考虑的替代方案
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
构建职责更加清晰:
- `packages/<group>/<pkg>``vendor/*` 下的每个模块有一份本地 tsconfig同时服务于构建、类型检查和直接运行源码的工具`tsx``vitest`)。
- `packages/<group>/<pkg>``vendor/*` 下的每个模块有一份本地 tsconfig同时服务于构建、类型检查和直接运行源码的工具`dsh` 源码 loader、`tsx``vitest`)。
- `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js``.d.ts` 输出,打包器仅负责 `lib/index.*`
- `lib/types/*.d.ts``.d.ts.map` 是发布用的声明输出。
- `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db
2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9
2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f
2026-06-19-acp-snapshot-tests.zh.md: 5c239d2c2589708050cd6199231ec5047f225107

View File

@@ -57,7 +57,7 @@ A snapshot run asserts **two** normalized surfaces, because the harness's extern
The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits.
Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout expected output; normalized session equality never overwrites the replay fixture.
Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes.
### Isolation: normalization now, sandbox later

View File

@@ -57,7 +57,7 @@ Status: implemented
两个表面互补stdout 覆盖精简的自动化线协议JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。
规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture
规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record``refresh` 模式负责
### 隔离:当前靠归一化,后续可加沙箱

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
2026-07-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306
2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e
2026-07-25-scriptable-llm-wire-fault-server.zh.md: 35b27efa99b625fa3c815bbe58fef6a138477e55

View File

@@ -12,7 +12,7 @@ Connection refusal, a reset before the first event, clean EOF without `[DONE]`,
## Decision
`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
`@deepseek-ai/dsh-llm-mock-server` is a private support package with an importable Node HTTP server. The repository-local `pnpm run mock:llm` source entry provides a standalone process for manual fault injection; the package exposes no installable binary. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection.
@@ -32,10 +32,12 @@ Package tests exercise every request behavior, split UTF-8 request decoding, HTT
**Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise.
**Expose an installable workspace binary** — rejected because pnpm links dependency binaries before repository build outputs exist, coupling clean installs to a test-only artifact. The repository-local source command supports the same manual fault injection without adding a package installation surface.
**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs.
## Consequences
Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history.
The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
The server adds a private support package and behavior vocabulary that must remain compatible with both direct tests and repository-local CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.

View File

@@ -12,9 +12,9 @@ Status: implemented
## 决策
`@deepseek-ai/dsh-llm-mock-server` 是一个支持包package提供可导入的 Node HTTP 服务器和独立 CLI命令行界面。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token捕获请求并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败只有设置 `repeatLast` 才会重复最后一个行为。
`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包package提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token捕获请求并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败只有设置 `repeatLast` 才会重复最后一个行为。
请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI(命令行界面)的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed允许调用方提供相对权重并内置一套偏重成功结果的压力测试配置将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力并非对生产事故发生频率的估算`connection_refused` 仍不进入请求级随机池。
@@ -32,10 +32,12 @@ Status: implemented
**仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。
**公开可安装的 workspace 二进制命令**不予采纳。pnpm 会在仓库构建产物存在之前链接依赖项的二进制命令,从而让干净安装与仅供测试的产物产生耦合。仓库内的源码命令支持相同的手动故障注入,而不会新增包安装接口。
**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。
## 后果
开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。
服务器新增了一个支持包、可执行构建入口和行为词汇,者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
服务器新增了一个私有支持包和一套行为词汇,者必须同时兼容直接测试与仓库内的 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。

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 .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117
2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1
2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db

View File

@@ -51,7 +51,7 @@ declare module 'cordis' {
- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
- The package owns `./invariant` (every served key has a live registration).
@@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
## Alternatives considered

View File

@@ -51,7 +51,7 @@ declare module 'cordis' {
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通host 侧单元、协议块、React 钩子)——没有第二张 DTO 表也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
- **host 是投影唯一的计算地点。** 框架正向驱动eager drive每个已注册的单元每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧。这消除了双重实现陷阱plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存persisted projection cache**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题变成一次索引读至多外加一小段尾部回放session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach由活转冷的时刻。两次写入之间崩溃的代价是尾部回放更长一些绝不会是值出错。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存persisted projection cache**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion``seq` = 水位线,`val` = 状态 JSON。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题变成一次索引读至多外加一小段尾部回放session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach由活转冷的时刻。两次写入之间崩溃的代价是尾部回放更长一些绝不会是值出错。
- 领域的输入事件集由领域自己选择todos 只折叠 `todo/write`plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
- 注册是 effectdisposer 随 fiber 走):插件卸载后其 key 从后续响应中消失客户端将其读作能力缺失——HMR热模块替换语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `
2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1在此之前 fixture测试前置数据喂合成帧
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
4. **领域重新对接**(在 1+2 之后):先 todo单元进 `tool-todo`,删掉搭载字段),再 plan双事件单元、RPC 下线、开关改发 `/plan`),最后 goal`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
## Alternatives considered

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847

View File

@@ -0,0 +1,57 @@
# Agent Note: Storage root placement and derived-medium recovery
Status: proposed
English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
## Problem
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
## Proposal
Two independent changes, one per gap.
### One global storage root, resolved once
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')``~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
### Declared derived media: reset instead of reject
- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default.
- `KvFacet` gains one primitive: `destroy(descriptor): Promise<void>` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy.
- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.
- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
## Alternatives considered
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
**Rename the damaged medium aside (`<unit>.json.corrupt-<ts>`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec.
**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
## Acceptance criteria
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
- The same damage to `workspace.json` still fails boot loudly.
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
## Risks
- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content.
- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.

View File

@@ -0,0 +1,57 @@
# Agent Note存储根目录落点与派生介质恢复
Status: proposed
[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文
## Problem
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`apps/cli/cordis.yml——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()`packages/storage/storage-json/src/index.ts——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts。净效果会话日志跨启动目录全局共享`workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit``malformed-medium`/`version-mismatch` 失败packages/storage/storage-json/src/format.tsschema 漂移的记录让域 open 以 `invalid-record` 失败packages/storage/storage-domain/src/index.ts拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc"version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
## Proposal
两个独立改动,一个缺口一个。
### 全局唯一存储根,构造时 resolve 一次
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json``root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
### 声明派生介质:损坏时重置而非拒绝
- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'``workspace` 保持默认。
- `KvFacet` 增加一个原语:`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质json删文件sqlitedrop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。
- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')``DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found``facet-unsupported``already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。
- 有了这个,缓存域 spec 的 version 字段才获得其本意bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
## Alternatives considered
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catchfacility 是唯一已经在分类 open 失败的地方。
**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
**把损坏介质改名旁置(`<unit>.json.corrupt-<ts>`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。
**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
## Acceptance criteria
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
## Risks
- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解重置只在三个确定性解析期代码上触发ENOENT 本来就是「空 unit」,一切 I/O 错误EACCES、EIO大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。

View File

@@ -59,6 +59,8 @@ jobs:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -126,6 +128,8 @@ jobs:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -204,6 +208,8 @@ jobs:
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -267,6 +273,8 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -318,6 +326,8 @@ jobs:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -418,6 +428,8 @@ jobs:
fetch-depth: 2
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -495,6 +507,8 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -528,6 +542,8 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -559,6 +575,8 @@ jobs:
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
@@ -651,6 +669,8 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
# The Windows lanes deliberately skip the store cache like the required
# windows job; an empty cache input disables setup-node's caching.
@@ -740,6 +760,8 @@ jobs:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
# Unlike the larger-runner suite, both platforms cache the store here:
# the consolidated topology measures cache mechanics as workload.

View File

@@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
## Conventions
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)).
- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9
README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 6b3a31a30a941e67341a518a7e32b1bf99eee8b0
README.zh.md: 133c721612012b8fd1327344ae4e2951d05047cb

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface:
@@ -16,6 +16,8 @@ The TUI surface:
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
## Install (developer machine)
@@ -26,4 +28,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

View File

@@ -4,7 +4,7 @@
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent智能体`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面:
@@ -16,6 +16,8 @@ TUI 界面:
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
## 安装(开发机)
@@ -26,4 +28,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts``scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`export 使用原生 ESM整个依赖图不含 TSX/JSX也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH`loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
`pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。

View File

@@ -86,6 +86,19 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# Common pi-ai provider routes read credentials and endpoint overrides from the
# boot's layered environment.
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
baseURL: !!js process.env.OPENAI_BASE_URL
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
baseURL: !!js process.env.ANTHROPIC_BASE_URL
# Transient-failure recovery around the loop's model calls (same policy as
# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
- id: llm-retry
@@ -116,12 +129,59 @@
- id: workspace
name: '@deepseek-ai/dsh-workspace'
# Persisted projection cache: durable per-session checkpoints of every
# registered projection unit (json backend → ./.storages/session_projcache.json,
# beside workspace.json), throttled between the two mandatory points
# (turn/end + detach), serving cold listings without full-log loads.
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
writeEveryEvents: 200
writeIntervalMs: 5000
# Managed child-process groups for the bash executor (spawn/kill/output plumbing).
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
# The sandboxed product path (the acp-agent composition): per-platform
# runner provider, the shared policy home, the confined bash executor, and
# the approval seam its escalation asks through. The web deployment default
# is danger-full-access + never (same behavior as the former bash-local
# rows); DSH_PERMISSION_MODE opts a process into a confined default, and
# per-session switches ride the /permission command's knob events.
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
# Presets over the two knobs (requires the confining executor + approval):
# the web permission chip's table, served through the permissions projection
# and switched through /permission.
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
@@ -133,9 +193,11 @@
name: '@deepseek-ai/dsh-tool-tasks'
# fs cwd stays the package default (process.cwd()) — the same value the
# gateway injects into session.cwd, so paths and sessions agree.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
# gateway injects into session.cwd, so paths and sessions agree. The
# sandboxed backend rides the SAME policy as bash: write/edit fence by the
# effective mode, so read/write/edit stay available under every mode.
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
@@ -165,6 +227,18 @@
- id: commands
name: '@deepseek-ai/dsh-commands'
# Goal service + automatic same-session continuation + the /goal command.
# The GoalService registers the 'goal' session projection unit; the web
# GoalBar reads it through useProjection.
- id: goal
name: '@deepseek-ai/dsh-goal'
- id: goal-session
name: '@deepseek-ai/dsh-goal-session'
- id: command-goal
name: '@deepseek-ai/dsh-command-goal'
# Plan mode registers /plan (the first real command on the web surface).
# Section text mirrors examples/tui-agent/cordis.yml (the reference
# deployment); plan-mode throws at load on an empty section.
@@ -240,6 +314,13 @@
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
# Directory-picking package, dual-face: the node half serves the gateway's
# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
# slots — one row composes the whole interaction. Swap point: mount
# '-native' instead for the host-display OS chooser.
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-browse'
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
@@ -316,10 +397,22 @@
- id: ui-subagent
name: '@deepseek-ai/dsh-client-ui-subagent'
# Goal surface: GoalBar in the input dock over the goal session projection.
- id: ui-goal
name: '@deepseek-ai/dsh-client-ui-goal'
# Model selection: the /model popupSelect + composer seat over session.models.
- id: ui-model
name: '@deepseek-ai/dsh-client-ui-model'
# The /permission popup picker (hostBacked over the host /permission command).
- id: ui-permission
name: '@deepseek-ai/dsh-client-ui-permission'
# Plan control: the composer plan seat over the plan projection + /plan channel.
- id: ui-plan
name: '@deepseek-ai/dsh-client-ui-plan'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'

View File

@@ -20,7 +20,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -28,9 +28,12 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
@@ -42,21 +45,31 @@
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
@@ -84,6 +97,7 @@
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",

View File

@@ -9,6 +9,7 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
@@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so both read this snapshot. Derived entries are
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
* an IP-literal Host is safe on any port, and the bound port may be
* OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -79,6 +115,8 @@ export interface AppCLIEntryOptions {
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
}
/**
@@ -91,6 +129,14 @@ export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
/**
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
* the /api trust fence was configured with. Display reads this instead of
* re-sampling, so the advertised LAN URL can never name an address the
* fence rejects. Empty unless the effective bind is all-interfaces.
*/
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
@@ -152,6 +198,13 @@ export class AppCLIEntry {
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 2b: authorities for the /api browser-trust fence (rationale on
// resolveLanTrust).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
this.lanAddresses = lanAddresses
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())

View File

@@ -40,6 +40,8 @@ interface WebInvocation {
port?: number
dev: boolean
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
trustedHosts?: string[]
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
@@ -51,6 +53,7 @@ interface WebOptions {
port?: string
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
}
/**
@@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation {
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
}
@@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a

View File

@@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts)
break
}
case 'headless': {

View File

@@ -0,0 +1,216 @@
/**
* Node module resolve hook for the `dsh` source launcher. It projects the root
* tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
* handling to Node's native transform-types runtime.
* @module @deepseek-ai/dsh/tsconfig-paths-loader
*/
import { readFile, stat } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
import ts from 'typescript'
interface LoaderData {
tsconfigPath: string
}
interface PackageManifest {
name?: string
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
}
interface PathRule {
pattern: string
prefix: string
suffix: string
targets: readonly string[]
}
interface PathsCompilerOptions {
readonly baseUrl?: string
readonly paths?: ts.MapLike<string[]>
readonly pathsBasePath?: string
}
// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
/**
* Resolve package imports through one parsed tsconfig paths table.
*
* Manifest reads are process-scoped and memoized by path. Only matched source
* aliases enter the cache, bounding it to directories participating in source
* resolution.
*/
export class TsconfigPathsResolver {
private readonly rules: readonly PathRule[]
private readonly configDirectory: string
private readonly manifests = new Map<string, Promise<PackageManifest | undefined>>()
private constructor(configDirectory: string, paths: ts.MapLike<string[]>) {
this.configDirectory = configDirectory
this.rules = Object.entries(paths)
.map(([pattern, targets]) => {
const wildcard = pattern.indexOf('*')
return {
pattern,
prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
targets,
}
})
.sort((left, right) => {
const leftExact = left.pattern.includes('*') ? 0 : 1
const rightExact = right.pattern.includes('*') ? 0 : 1
return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
})
}
/**
* Parse a tsconfig including its `extends` chain.
* @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
* @returns A resolver backed by that path table.
*/
static create(tsconfigPath: string): TsconfigPathsResolver {
let unrecoverable: ts.Diagnostic | undefined
const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
})
if (parsed === undefined) {
const detail = unrecoverable === undefined
? 'unknown configuration error'
: ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
}
const options = parsed.options as PathsCompilerOptions
const paths = options.paths
if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
return new TsconfigPathsResolver(configDirectory, paths)
}
/**
* Resolve one bare package specifier to a source file when the importing
* package (or config-directory owner) declares that package at runtime.
* @param specifier Module specifier passed to Node.
* @param parentURL Importing file or Loader config-directory URL.
* @returns Source file URL, or `undefined` when normal Node resolution owns the request.
*/
async resolve(specifier: string, parentURL: string | undefined): Promise<string | undefined> {
const packageName = packageNameFromSpecifier(specifier)
if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
const matched = this.match(specifier)
if (matched === undefined) return undefined
const configParent = parentURL.endsWith('/')
const parentPath = fileURLToPath(parentURL)
const startDirectory = configParent ? parentPath : dirname(parentPath)
if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
for (const target of matched.targets) {
const substituted = target.replace('*', matched.wildcard)
const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
if (candidate !== undefined) return pathToFileURL(candidate).href
}
return undefined
}
private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
for (const rule of this.rules) {
if (!rule.pattern.includes('*')) {
if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
continue
}
if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
return { targets: rule.targets, wildcard }
}
return undefined
}
private async isDeclaredRuntimeDependency(
startDirectory: string,
packageName: string,
searchAncestors: boolean,
): Promise<boolean> {
for (let directory = startDirectory; ; directory = dirname(directory)) {
const manifest = await this.readManifest(join(directory, 'package.json'))
if (manifest !== undefined) {
if (declaresRuntimeDependency(manifest, packageName)) return true
if (!searchAncestors) return false
}
const parent = dirname(directory)
if (parent === directory) return false
}
}
private readManifest(path: string): Promise<PackageManifest | undefined> {
let pending = this.manifests.get(path)
if (pending !== undefined) return pending
pending = readFile(path, 'utf8').then(
content => JSON.parse(content) as PackageManifest,
(error: unknown) => {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
},
)
this.manifests.set(path, pending)
return pending
}
}
let resolver: TsconfigPathsResolver | undefined
/** Initialize the hook worker from the source-launch preloader. */
export function initialize(data: LoaderData): void {
resolver = TsconfigPathsResolver.create(data.tsconfigPath)
}
/** Resolve declared workspace packages to source and delegate every other request to Node. */
export async function resolveHook(
specifier: string,
context: ResolveHookContext,
nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
): Promise<ResolveFnOutput> {
const url = await resolver?.resolve(specifier, context.parentURL)
return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
}
// Node customization hooks discover this exact export name.
export { resolveHook as resolve }
function packageNameFromSpecifier(specifier: string): string | undefined {
if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
return undefined
}
const segments = specifier.split('/')
return specifier.startsWith('@')
? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
: segments[0] || undefined
}
function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
return manifest.name === packageName
|| packageName in (manifest.dependencies ?? {})
|| packageName in (manifest.optionalDependencies ?? {})
|| packageName in (manifest.peerDependencies ?? {})
}
async function existingSourcePath(base: string): Promise<string | undefined> {
const extension = extname(base)
if (extension === '.tsx') return undefined
const candidates = extension === ''
? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
: [base]
for (const candidate of candidates) {
try {
if ((await stat(candidate)).isFile()) return candidate
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
return undefined
}

View File

@@ -24,7 +24,10 @@ import {
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { Context } from 'cordis'
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
import {
TUI_GOODBYE_MESSAGE_KEY,
type TuiResumeHost,
} from '@deepseek-ai/dsh-tui'
const NAME = 'dsh'
@@ -70,8 +73,10 @@ export async function runTui(config: string | undefined, resumeSessionId: string
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
const resumeCommand = (sessionId: string): string =>
`${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
async handoff(sessionId): Promise<never> {
async handoff(sessionId, cwd): Promise<never> {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
// Rebuild argv from the parsed config plus the selected id: TUI mode's
@@ -83,6 +88,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string
`--resume=${sessionId}`,
...config !== undefined ? ['--config', config] : [],
]
try {
process.chdir(cwd)
} catch (error) {
throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`)
}
try {
await current.fiber.dispose()
execve(process.execPath, nextArgv, process.env)
@@ -101,6 +111,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string
// Inject the resume id (or undefined) so the shipped config's `!!js`
// reads it as a bare identifier; then offer the in-place handoff host.
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
if (resumeSessionId !== undefined) {
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
}
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
},
)

View File

@@ -6,17 +6,14 @@
* gates them at boot.
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
@@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0'
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
*/
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
trustedHosts: string[] | undefined,
): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
@@ -38,6 +37,7 @@ export async function runWeb(
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
@@ -48,12 +48,11 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })

View File

@@ -35,6 +35,9 @@ describe('parseDshArgs', () => {
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {

View File

@@ -0,0 +1,33 @@
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
import { describe, expect, it, vi } from 'vitest'
import { resolveLanTrust } from '../src/app-cli-entry.ts'
vi.mock('node:os', () => ({
networkInterfaces: () => ({
lo0: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
],
en0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
],
en1: [
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
],
utun0: undefined,
}),
}))
describe('resolveLanTrust', () => {
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
})
})

View File

@@ -0,0 +1,180 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
class ResolverFixture {
readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
path(relativePath: string): string {
return join(this.root, relativePath)
}
write(relativePath: string, content = 'export {}\n'): string {
const path = this.path(relativePath)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
return path
}
writeJson(relativePath: string, value: unknown): string {
return this.write(relativePath, `${JSON.stringify(value)}\n`)
}
createResolver(paths: Record<string, string[]>): TsconfigPathsResolver {
const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
return TsconfigPathsResolver.create(tsconfigPath)
}
parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
return pathToFileURL(this.path(relativePath)).href
}
dispose(): void {
rmSync(this.root, { recursive: true, force: true })
}
}
const fixtures: ResolverFixture[] = []
function fixture(): ResolverFixture {
const value = new ResolverFixture()
fixtures.push(value)
return value
}
afterEach(() => {
for (const value of fixtures.splice(0)) value.dispose()
})
describe('TsconfigPathsResolver', () => {
it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
const files = fixture()
files.writeJson('consumer/package.json', {
dependencies: {
'@scope/feature-name': '*',
'@scope/feature-other': '*',
'@scope/plain-suffix': '*',
},
})
files.write('targets/exact.ts')
files.write('targets/prefix/other.ts')
files.write('targets/generic/feature-other.ts')
files.write('targets/suffix/plain.ts')
files.write('targets/generic/plain-suffix.ts')
const resolver = files.createResolver({
'@scope/*': ['./targets/generic/*'],
'@scope/*-suffix': ['./targets/suffix/*'],
'@scope/feature-*': ['./targets/prefix/*'],
'@scope/feature-name': ['./targets/exact.ts'],
})
await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
.resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
.resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
.resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
})
it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
const files = fixture()
files.writeJson('consumer/package.json', {
name: 'self-package',
dependencies: { dependency: '*' },
optionalDependencies: { optional: '*' },
peerDependencies: { peer: '*' },
})
for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
files.write(`targets/${name}.ts`)
}
const resolver = files.createResolver(Object.fromEntries(
['self-package', 'dependency', 'optional', 'peer', 'undeclared']
.map(name => [name, [`./targets/${name}`]]),
))
for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
await expect(resolver.resolve(name, files.parentURL()))
.resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
}
await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
})
it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
const files = fixture()
const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
files.writeJson('consumer/package.json', {
dependencies: Object.fromEntries(names.map(name => [name, '*'])),
})
files.write('targets/plain.ts')
files.write('targets/module.mts')
files.write('targets/common.cts')
files.write('targets/directory/index.ts')
files.write('targets/component.tsx')
const resolver = files.createResolver({
'plain-ts': ['./targets/plain'],
'module-mts': ['./targets/module'],
'common-cts': ['./targets/common'],
'directory': ['./targets/directory'],
'tsx-implicit': ['./targets/component'],
'tsx-explicit': ['./targets/component.tsx'],
'missing': ['./targets/missing'],
})
for (const [name, target] of [
['plain-ts', 'targets/plain.ts'],
['module-mts', 'targets/module.mts'],
['common-cts', 'targets/common.cts'],
['directory', 'targets/directory/index.ts'],
] as const) {
await expect(resolver.resolve(name, files.parentURL()))
.resolves.toBe(pathToFileURL(files.path(target)).href)
}
await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
})
it('anchors inherited paths at the config that declared them', async () => {
const files = fixture()
files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
files.write('targets/custom.ts')
files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
const resolver = TsconfigPathsResolver.create(customTsconfig)
await expect(resolver.resolve('custom', files.parentURL()))
.resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
})
it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
const files = fixture()
files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
const target = files.write('targets/matched.ts')
const tsconfigPath = files.writeJson('tsconfig.json', {
compilerOptions: { paths: { matched: ['./targets/matched'] } },
})
initialize({ tsconfigPath })
const context: ResolveHookContext = {
conditions: [],
importAttributes: {},
parentURL: files.parentURL(),
}
const nextResolve = vi.fn(async (
specifier: string,
_context: ResolveHookContext,
): Promise<ResolveFnOutput> => ({ url: `next:${specifier}` }))
await expect(resolveHook('matched', context, nextResolve))
.resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
expect(nextResolve).not.toHaveBeenCalled()
for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
}
})
})

View File

@@ -50,6 +50,9 @@
{
"path": "../../packages/client/ui-models"
},
{
"path": "../../packages/client/ui-permission"
},
{
"path": "../../packages/client/locale"
},
@@ -62,6 +65,9 @@
{
"path": "../../packages/client/ui-conversation"
},
{
"path": "../../packages/client/ui-plan"
},
{
"path": "../../packages/client/ui-trajectory"
},

View File

@@ -119,6 +119,10 @@ it('projects titles and routes the next turn through the selected model in the b
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
const revised = titleSurfaces(revisedLabel)
// fx-alpha carries the fixture's resident answerable approval, so the
// approval panel has taken over the composer (the real takeover behavior);
// answer it to restore the composer chrome before asserting the model seat.
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
const modelTrigger = await screen.findByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash推理等级 High',
})

View File

@@ -1,21 +1,30 @@
- banner:
- navigation "Session hierarchy":
- 'button "Using ONE run_code program: run" [disabled]'
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
- button:
- img
- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
- button
- text: Read missing.txt
- img
- text: Code Run bash echo and catch missing file read
- img
- text: Bash Echo CODE_ROUND_OK Read
- button "missing.txt"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
@@ -23,10 +32,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,7 +1,6 @@
- banner:
- navigation "Session hierarchy":
- button "Use only Cordis tools. First" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -13,14 +12,16 @@
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to:":
- img
- img
- text: "Think The user wants me to:"
- button:
- img
- img
- text: Inspect temporary
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
- button [expanded]:
@@ -29,12 +30,15 @@
- button "复制"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
- button:
- img
- img
- text: Unmount temporary Plugin dyn-1
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
- img
- img
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
- paragraph: CORDIS_UI_DONE
@@ -42,7 +46,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,17 +1,25 @@
- banner:
- navigation "Session hierarchy":
- button "Use the bash tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
- text: Echo the test string
- img
- text: Bash Echo the test string
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
- img
- img
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
- paragraph: DONE
@@ -19,10 +27,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,3 +1,4 @@
- button "New session"
- button "Collapse sidebar":
- img
- button "New session":
@@ -27,11 +28,13 @@
- textbox "Describe what you want to build"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 详情

View File

@@ -1,13 +1,19 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with the single word" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with the single word LIGHTHOUSE and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
@@ -15,10 +21,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,21 +1,28 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- paragraph: partial
- text: 已停止 0 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,19 +1,26 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,13 +1,19 @@
- banner:
- navigation "Session hierarchy":
- button "Reply with a one-sentence description" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Reply with a one-sentence description of event sourcing, then stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
@@ -15,10 +21,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,7 +1,6 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -14,12 +13,15 @@
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
- paragraph: DONE
@@ -27,10 +29,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -1,19 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答1 题)"
- button "▸ 问题内容"
- text: 请在原客户端处理web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]

View File

@@ -1,19 +1,27 @@
- banner:
- navigation "Session hierarchy":
- button "Use the ask_user_question tool to" [disabled]
- text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
- button "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
- paragraph: Great, let's move forward. BANANA!
@@ -21,10 +29,12 @@
- textbox "Message the agent"
- button "Add attachment":
- img
- text: Danger Full Access
- combobox "Access mode":
- option "Read-only" [selected]
- option "Read-write"
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- option "Read Only"
- option "Workspace Write"
- option "Danger Full Access" [selected]
- button "选择模型,当前 DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]

View File

@@ -0,0 +1,23 @@
- dialog "选择工作区目录":
- heading "选择工作区目录" [level=2]
- navigation:
- button "主目录"
- img
- button "browse-golden"
- button "编辑路径"
- list:
- listitem:
- button "alpha":
- img
- text: alpha
- img
- listitem:
- button "beta":
- img
- text: beta
- img
- button "新建文件夹":
- img
- text: 新建文件夹
- button "取消"
- button "打开"

View File

@@ -37,6 +37,15 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
// Dual-face host package: its browser half fills the directory-flow holes
// (the same composition row apps/cli mounts for the node-side backend).
{
id: '@deepseek-ai/dsh-host-directory-picker-browse',
dir: '../host/directory-picker-browse',
url: '/plugins/directory-picker-browse.js',
rev: 'fx',
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
},
]
const bundles = new Map(PLUGINS.map(plugin => [
@@ -174,6 +183,37 @@ it('locks the composer in the New Session view state until a Workspace is chosen
`)
})
it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
boot('?fixture=empty')
await findLockedComposer()
fireEvent.click(workspaceChip())
const menu = await screen.findByRole('menu')
// The composed flow package occupies the directory-flow hole, so the
// picking affordance is present (no advertised-kind read exists anymore).
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
.toEqual(['Open local folder…', 'Create a new workspace'])
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
// The browse occupant renders the Select Workspace Directory dialog at the
// fixture home; select Documents, advance into project, and adopt it.
const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
// Row targeting goes through the visible label text: listitem accessible-name
// computation differs across dom-accessibility-api environments, while the
// row's name span is stable (clicks bubble to the row button).
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
// Open disables while the selection's child listing is in flight; wait for
// the enabled state or the click lands on a dead button on slow runners.
await waitFor(() => {
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
}, { timeout: 10_000 })
fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
await findHeroComposer()
await waitFor(() => {
expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
})
})
it('selects the recent Workspace and opens its blank Session on first load', async () => {
boot('?fixture')

View File

@@ -13,8 +13,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
@@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i
// spec needs any one cold session row, not new recorded content.
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
const SEED_ID = 'workspace-management-web-e2e'
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
@@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let pickedDirectory: string | null = null
/**
* Drive the in-app browser to a directory via its path-edit affordance,
* confirm it, and wait for the adoption to settle host-side (workspace
* registered + the flow's New-Session agent up), so later test steps can't
* race the in-flight blank-session attach.
*/
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
const agentsBefore = scaffold.ctx.agents.list().length
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(path)
await dialog.getByLabel('编辑路径').press('Enter')
await dialog.getByRole('button', { name: '打开' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(path),
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
}
}
beforeAll(async () => {
scaffold = await launchWebScaffold({})
scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
rpcId: request.rpcId,
result: { ok: true, value: { path: pickedDirectory } },
})
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
@@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
// Register the scaffold's existing project directory through the real UI.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
).not.toBeUndefined()
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
@@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await openLocalFolder(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
@@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
pickedDirectory = oldPath
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await openLocalFolder(oldPath)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(oldPath),
{ timeout: 10_000 },
@@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('matches the directory-browser dialog aria golden at a staged directory', async () => {
// A staged subtree under the scaffold cwd keeps the listing deterministic
// (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
// at the cwd collapses the breadcrumb ancestry into the Home crumb — no
// machine-specific path segments or real $HOME contents enter the golden.
const staged = join(scaffold.workspaceCwd, 'browse-golden')
await mkdir(join(staged, 'alpha'), { recursive: true })
await mkdir(join(staged, 'beta'), { recursive: true })
// homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
// at the scaffold cwd so the golden's ancestry collapses everywhere.
const realHome = process.env.HOME
const realUserProfile = process.env.USERPROFILE
process.env.HOME = scaffold.workspaceCwd
process.env.USERPROFILE = scaffold.workspaceCwd
try {
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
await dialog.getByLabel('编辑路径').fill(staged)
await dialog.getByLabel('编辑路径').press('Enter')
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
await dialog.getByRole('button', { name: '取消' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
} finally {
if (realHome === undefined) delete process.env.HOME
else process.env.HOME = realHome
if (realUserProfile === undefined) delete process.env.USERPROFILE
else process.env.USERPROFILE = realUserProfile
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('shows the session hover card after a dwell on the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
// Expand Ungrouped to reveal the seeded session row, then dwell on it
@@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
expect(tripwire.warnings).toEqual([])
// This spec mints no fixture directory contents of its own; the seed it
// reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
// The directory-browser aria golden is this spec's one owned artifact;
// the seed it reuses is owned (and inventory-guarded) by seeded-history.
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
})
})

15
bin/dsh
View File

@@ -1,7 +1,7 @@
#!/bin/sh
# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's
# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
# current working tree — code changes apply on the next launch, no build step.
# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native
# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh)
# always executes the current working tree without a build step.
set -eu
# Resolve symlink chains without readlink -f (not on every macOS).
@@ -15,7 +15,8 @@ while [ -L "$script" ]; do
done
root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
# tsx is imported by absolute path because bare `--import tsx` resolves from
# the invoking cwd, which is usually outside this repository.
export TSX_TSCONFIG_PATH="$root/tsconfig.json"
exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@"
# The preloader projects this checkout's tsconfig paths into Node resolution;
# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook).
exec node --experimental-transform-types \
--import "$root/scripts/tspath-loader.ts" \
"$root/apps/cli/src/bin.ts" "$@"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897
architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574
architecture.md: 2ae982eba49b6dbd2365496915f9917071167813
architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4

View File

@@ -25,27 +25,28 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
| ctx key | Package family | Role |
|---|---|---|
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure |
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry, streaming model calls |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request and surface pressure |
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend |
| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for bash, LSP, and ACP subagent backends |
| `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions |
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy |
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry |
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure |
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure |
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning |
| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider |
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
## Event

View File

@@ -28,7 +28,7 @@
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 |
| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 |
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 |
| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 |
| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash、LSP 与 ACP subagent 后端使用的受管子进程树 |
| `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 |
| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 |
| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 |
@@ -44,8 +44,9 @@
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 |
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native``browse` 交互) |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
## 事件

View File

@@ -77,6 +77,8 @@ flowchart LR
pkg_session_projection["session-projection"]
svc_sessionProjections["ctx.sessionProjections<br/>Session projection units"]
pkg_host_apiproxy["host-apiproxy"]
pkg_session_projection_cache["session-projection-cache"]
svc_sessionProjectionCache["ctx.sessionProjectionCache<br/>Persisted projection cache"]
svc_tui["ctx.tui<br/>Mounted-terminal interaction service"]
pkg_skill["skill"]
svc_skills["ctx.skills<br/>Skill provider registry"]
@@ -139,6 +141,10 @@ flowchart LR
svc_spillStore["ctx.spillStore<br/>Spill storage seam"]
pkg_spill_local["spill-local"]
pkg_spill_policy["spill-policy"]
pkg_directory_picker["directory-picker"]
svc_directoryPicker["ctx.directoryPicker<br/>Workspace-directory picking seam"]
pkg_directory_picker_native["directory-picker-native"]
pkg_directory_picker_browse["directory-picker-browse"]
pkg_webserver["webserver"]
svc_httpServer["ctx.httpServer<br/>HTTP route registration"]
pkg_connection["connection"]
@@ -162,6 +168,9 @@ flowchart LR
pkg_compact --> svc_compact
pkg_compact_basic --> svc_compact
pkg_compact_tool_result_prune --> svc_toolResultPrune
pkg_directory_picker --> svc_directoryPicker
pkg_directory_picker_browse --> svc_directoryPicker
pkg_directory_picker_native --> svc_directoryPicker
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
pkg_fs_sandbox --> svc_fs
@@ -184,6 +193,7 @@ flowchart LR
pkg_session_persistence_jsonl --> svc_sessionPersistence
pkg_session_persistence_sqlite --> svc_sessionPersistence
pkg_session_projection --> svc_sessionProjections
pkg_session_projection_cache --> svc_sessionProjectionCache
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
@@ -239,6 +249,7 @@ flowchart LR
svc_codeRuntime --> pkg_tools
svc_commands --> pkg_tui
svc_compact --> pkg_compact_basic
svc_directoryPicker --> pkg_apiproxy
svc_fs --> pkg_tool_fs
svc_httpServer --> pkg_connection
svc_httpServer --> pkg_hmr
@@ -261,6 +272,7 @@ flowchart LR
svc_sessionPersistence --> pkg_session_query
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
svc_sessionProjectionCache --> pkg_host_apiproxy
svc_sessionProjections --> pkg_host_apiproxy
svc_sessionProjections --> pkg_session_title
svc_sessionProjections --> pkg_tool_todo
@@ -336,6 +348,7 @@ flowchart LR
| `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
| `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. |
| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. |
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |
| `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
@@ -356,6 +369,7 @@ flowchart LR
| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). |
| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. |

View File

@@ -270,6 +270,27 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) ·
Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts)
## `@deepseek-ai/dsh-client-connection`
Requires: `httpServer` · `apiProxy`
```ts config-catalog
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
}
```
Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts)
## `@deepseek-ai/dsh-client-hmr`
Requires: `clientModuleHost` · `httpServer`
@@ -421,7 +442,7 @@ export interface Config {
}
```
Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts)
Source: [`packages/goal/goal/src/index.ts:118`](../packages/goal/goal/src/index.ts)
## `@deepseek-ai/dsh-hooks-claude`
@@ -486,7 +507,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
## `@deepseek-ai/dsh-host-apiproxy`
Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
@@ -502,6 +523,18 @@ export interface Config {
Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts)
## `@deepseek-ai/dsh-host-directory-picker-browse`
```ts config-catalog
/** Validated plugin configuration. */
export interface Config {
/** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */
maxEntries: number
}
```
Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/host/directory-picker-browse/src/index.ts)
## `@deepseek-ai/dsh-host-webserver`
```ts config-catalog
@@ -832,7 +865,7 @@ export interface PresetSpec {
Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md)
Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts)
Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts)
## `@deepseek-ai/dsh-plan-mode`
@@ -846,7 +879,7 @@ export interface PlanModeConfig {
}
```
Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:68`](../packages/plan/plan-mode/src/index.ts)
## `@deepseek-ai/dsh-pty-local`
@@ -1048,6 +1081,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-projection-cache`
Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
```ts config-catalog
/**
* Plugin config. Both throttle triggers are deployment choices with no
* universally correct value, so the composition states them explicitly
* (cordis.yml); the two mandatory write points (`turn/end` and session
* disposal) are policy, not tunables, and always fire.
*/
export interface Config {
/** Committed events per session that force a durable checkpoint write between mandatory points. */
writeEveryEvents: number
/** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
writeIntervalMs: number
}
```
Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts)
## `@deepseek-ai/dsh-session-query-sqlite`
Requires: `sessions`
@@ -1824,12 +1878,12 @@ export interface Config extends TuiConfig {
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
sessionId?: string
/**
* Shell command fallback printed on exit or after selecting a session when
* the host cannot hand off in place. Every `{session}` becomes the selected
* id; the TUI never executes this text. Absent disables only the fallback,
* not the interactive selector.
* Skill name auto-invoked as this session's first user turn, exactly as if
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
* turn to the user.
*/
resumeCommand?: string
initialSkill?: string
}
/** Interaction and presentation settings for the pi-tui terminal mode. */
@@ -1971,7 +2025,7 @@ export interface Config {
export type ApprovalPolicy = 'ask' | 'never'
```
Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts)
Source: [`packages/ui/user-approval/src/index.ts:202`](../packages/ui/user-approval/src/index.ts)
## `@deepseek-ai/dsh-web`
@@ -2145,15 +2199,17 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont
These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts))
- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts))
- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts))
- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts))
- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
- `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
@@ -2168,6 +2224,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
@@ -2192,6 +2249,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts))
@@ -2209,6 +2267,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
- `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
@@ -2218,6 +2277,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
- `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
- `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))

View File

@@ -256,7 +256,7 @@ Read a service from the store without the inject requirement.
**Returns** the service value, or `undefined` when not (yet) provided.
[Source](../../../vendor/cordis/src/reflect.ts#L16)
[Source](../../../vendor/cordis/src/reflect.ts#L17)
### ctx.set(name, value)
@@ -281,7 +281,7 @@ Only the fiber that provided the service may set it; setting an unprovided name
- `name` — the service name.
- `value` — the new service value.
[Source](../../../vendor/cordis/src/reflect.ts#L28)
[Source](../../../vendor/cordis/src/reflect.ts#L29)
### ctx.provide(name, value)
@@ -311,7 +311,7 @@ The service becomes visible to dependents in the same isolation scope once the f
**Returns** a disposer that unregisters the service.
[Source](../../../vendor/cordis/src/reflect.ts#L43)
[Source](../../../vendor/cordis/src/reflect.ts#L44)
### ctx.accessor(name, options)
@@ -335,7 +335,7 @@ The accessor is removed when the current fiber unloads. Throws if the name is al
- `name` — the context property name.
- `options` — the `get` hook and optional `set` hook.
[Source](../../../vendor/cordis/src/reflect.ts#L55)
[Source](../../../vendor/cordis/src/reflect.ts#L56)
### ctx.mixin(name, mixins)
@@ -361,4 +361,4 @@ Each mixed-in key becomes an accessor that forwards to the service (binding meth
- `name` — the context property holding the source service.
- `mixins` — keys to forward, or a source-key → ctx-key map.
[Source](../../../vendor/cordis/src/reflect.ts#L66)
[Source](../../../vendor/cordis/src/reflect.ts#L67)

View File

@@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently.
**Returns** a promise resolving once every listener has settled.
[Source](../../../vendor/cordis/src/events.ts#L43)
[Source](../../../vendor/cordis/src/events.ts#L44)
### ctx.emit(name, ...args)
@@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values.
- `name` — the event name.
- `args` — arguments passed to every listener.
[Source](../../../vendor/cordis/src/events.ts#L52)
[Source](../../../vendor/cordis/src/events.ts#L53)
### ctx.serial(name, ...args)
@@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails.
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
[Source](../../../vendor/cordis/src/events.ts#L62)
[Source](../../../vendor/cordis/src/events.ts#L63)
### ctx.bail(name, ...args)
@@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails.
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
[Source](../../../vendor/cordis/src/events.ts#L72)
[Source](../../../vendor/cordis/src/events.ts#L73)
### ctx.waterfall(name, ...args)
@@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis
**Returns** the outermost listener's return value.
[Source](../../../vendor/cordis/src/events.ts#L85)
[Source](../../../vendor/cordis/src/events.ts#L86)
### ctx.on(name, listener, options?)
@@ -144,7 +144,7 @@ Register an event listener owned by the current fiber.
**Returns** a disposer removing the listener; `true` if it was still registered.
[Source](../../../vendor/cordis/src/events.ts#L96)
[Source](../../../vendor/cordis/src/events.ts#L97)
### ctx.once(name, listener, options?)
@@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call.
**Returns** a disposer removing the listener; `true` if it was still registered.
[Source](../../../vendor/cordis/src/events.ts#L105)
[Source](../../../vendor/cordis/src/events.ts#L106)
## EventOptions
@@ -184,7 +184,7 @@ interface EventOptions {
}
```
[Source](../../../vendor/cordis/src/events.ts#L111)
[Source](../../../vendor/cordis/src/events.ts#L112)
## DispatchMode
@@ -204,4 +204,4 @@ Event dispatch strategy used by the event service.
type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
```
[Source](../../../vendor/cordis/src/events.ts#L31)
[Source](../../../vendor/cordis/src/events.ts#L32)

View File

@@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber.
**Returns** a disposer that tears the effect down and settles once done.
[Source](../../../vendor/cordis/src/fiber.ts#L419)
[Source](../../../vendor/cordis/src/fiber.ts#L420)
### ctx.fiber
@@ -45,7 +45,7 @@ fiber: Fiber
The fiber (plugin runtime instance) that owns this context.
[Source](../../../vendor/cordis/src/fiber.ts#L11)
[Source](../../../vendor/cordis/src/fiber.ts#L12)
## The Fiber class
@@ -53,7 +53,7 @@ Runtime instance of one plugin application.
A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`.
[Source](../../../vendor/cordis/src/fiber.ts#L183)
[Source](../../../vendor/cordis/src/fiber.ts#L184)
### fiber.uid
@@ -64,7 +64,7 @@ public uid: number | null
Unique id within the registry; 0 for the root fiber, `null` once disposed.
[Source](../../../vendor/cordis/src/fiber.ts#L185)
[Source](../../../vendor/cordis/src/fiber.ts#L186)
### fiber.ctx
@@ -75,7 +75,7 @@ public readonly ctx: Context
The context this fiber's plugin runs in (extends the parent context).
[Source](../../../vendor/cordis/src/fiber.ts#L187)
[Source](../../../vendor/cordis/src/fiber.ts#L188)
### fiber.config
@@ -86,7 +86,7 @@ public config: any
The validated plugin config (updated by `update()`).
[Source](../../../vendor/cordis/src/fiber.ts#L189)
[Source](../../../vendor/cordis/src/fiber.ts#L190)
### fiber.state
@@ -97,7 +97,7 @@ public state
Current lifecycle state; transitions emit `internal/status`.
[Source](../../../vendor/cordis/src/fiber.ts#L191)
[Source](../../../vendor/cordis/src/fiber.ts#L192)
### fiber.dispose
@@ -108,7 +108,7 @@ public readonly dispose: () => Promise<void>
Dispose this fiber: unload the plugin, then settle once cleanup finished.
[Source](../../../vendor/cordis/src/fiber.ts#L193)
[Source](../../../vendor/cordis/src/fiber.ts#L194)
### fiber.store
@@ -119,7 +119,7 @@ public store: Dict<Impl> | undefined
Snapshot of required service implementations while loaded; `undefined` otherwise.
[Source](../../../vendor/cordis/src/fiber.ts#L195)
[Source](../../../vendor/cordis/src/fiber.ts#L196)
### fiber.inertia
@@ -130,7 +130,7 @@ public inertia: Promise<void> | undefined
The in-flight load/unload transition, if one is currently running.
[Source](../../../vendor/cordis/src/fiber.ts#L197)
[Source](../../../vendor/cordis/src/fiber.ts#L198)
### fiber.name
@@ -141,7 +141,7 @@ get name()
The plugin's display name, inherited from the nearest named ancestor, else `'root'`.
[Source](../../../vendor/cordis/src/fiber.ts#L340)
[Source](../../../vendor/cordis/src/fiber.ts#L341)
### fiber.assertActive()
@@ -159,7 +159,7 @@ Throw if the fiber has already been disposed.
**Returns** nothing when the fiber is still active.
[Source](../../../vendor/cordis/src/fiber.ts#L355)
[Source](../../../vendor/cordis/src/fiber.ts#L356)
### fiber.effect(execute, label?)
@@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber.
**Returns** a disposer that tears the effect down and settles once done.
[Source](../../../vendor/cordis/src/fiber.ts#L419)
[Source](../../../vendor/cordis/src/fiber.ts#L420)
### fiber.getEffects()
@@ -207,7 +207,7 @@ Return metadata for currently registered effects.
**Returns** one `EffectMeta` tree per labeled live effect.
[Source](../../../vendor/cordis/src/fiber.ts#L572)
[Source](../../../vendor/cordis/src/fiber.ts#L573)
### fiber.await()
@@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors.
**Returns** this fiber, once it has settled into a stable state.
[Source](../../../vendor/cordis/src/fiber.ts#L701)
[Source](../../../vendor/cordis/src/fiber.ts#L702)
### fiber.restart()
@@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config.
**Returns** a promise resolving once the reload settled.
[Source](../../../vendor/cordis/src/fiber.ts#L715)
[Source](../../../vendor/cordis/src/fiber.ts#L716)
### fiber.update(config, noSave?)
@@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o
**Returns** nothing; the restart runs behind the `internal/update` waterfall.
[Source](../../../vendor/cordis/src/fiber.ts#L733)
[Source](../../../vendor/cordis/src/fiber.ts#L734)
## Effect
@@ -292,7 +292,7 @@ type Effect<T = any> =
| AsyncEffect<T>
```
[Source](../../../vendor/cordis/src/fiber.ts#L82)
[Source](../../../vendor/cordis/src/fiber.ts#L83)
## Disposable
@@ -310,7 +310,7 @@ Disposers run in reverse registration order when the owning fiber unloads; they
type Disposable<T = any> = () => T
```
[Source](../../../vendor/cordis/src/fiber.ts#L73)
[Source](../../../vendor/cordis/src/fiber.ts#L74)
## EffectMeta
@@ -326,7 +326,7 @@ interface EffectMeta {
}
```
[Source](../../../vendor/cordis/src/fiber.ts#L95)
[Source](../../../vendor/cordis/src/fiber.ts#L96)
## CordisError
@@ -352,7 +352,7 @@ namespace CordisError {
}
```
[Source](../../../vendor/cordis/src/fiber.ts#L156)
[Source](../../../vendor/cordis/src/fiber.ts#L157)
## ValidationError
@@ -372,4 +372,4 @@ class ValidationError extends TypeError {
}
```
[Source](../../../vendor/cordis/src/fiber.ts#L18)
[Source](../../../vendor/cordis/src/fiber.ts#L19)

View File

@@ -30,7 +30,7 @@ Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloade
**Returns** the fiber; awaiting it settles once loading finished.
[Source](../../../vendor/cordis/src/registry.ts#L175)
[Source](../../../vendor/cordis/src/registry.ts#L176)
### ctx.plugin(plugin, ...args)
@@ -53,7 +53,7 @@ Load a plugin in the current context.
**Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors).
[Source](../../../vendor/cordis/src/registry.ts#L184)
[Source](../../../vendor/cordis/src/registry.ts#L185)
## Plugin
@@ -118,7 +118,7 @@ namespace Plugin {
}
```
[Source](../../../vendor/cordis/src/registry.ts#L91)
[Source](../../../vendor/cordis/src/registry.ts#L92)
## Inject
@@ -149,4 +149,4 @@ namespace Inject {
}
```
[Source](../../../vendor/cordis/src/registry.ts#L18)
[Source](../../../vendor/cordis/src/registry.ts#L19)

View File

@@ -524,7 +524,7 @@ Goal mutation accepted by one live agent. The matching context event is already
Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts)
Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts)
## `llm/*`

Some files were not shown because too many files have changed in this diff Show More