diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index a91608f093..ceccd5ead5 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -21,8 +21,8 @@ en: 0a4240d88cca240a zh: 8f5f1527caf571cd /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end: - en: 7dac236d6add3c3c - zh: 2a43a923a0646bb0 + en: 15fb56befd328339 + zh: 0ffb10ada7e52805 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation: en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 @@ -33,8 +33,8 @@ en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences: - en: 6a96d9ac06c222e5 - zh: b2814ab58348e831 + en: 16e1f2467c559315 + zh: a714ec6ff2722cf1 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered: en: b8f036f50cff7cd8 zh: 6d623c4a1f4314b1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 493eb8c506..28d718586b 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the 1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. -3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. +3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. @@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering ## Consequences -One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook. +One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references. Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f7bf1452d6..1bf50a15ba 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。 -3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。 +3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 @@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始 ## Consequences -Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。 +Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml index be7bf32098..de7db438b5 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.i18n.yaml @@ -12,8 +12,8 @@ en: 972bbe45fe8ac28e zh: 593b851c142894e1 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches: - en: f44b784545c6d9a2 - zh: 3eb6fcb9f348dec4 + en: 913d1dceab36d4a1 + zh: 049521c5127f3478 /agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings: en: b8278678170f1507 zh: 113a51ba5cf27de1 diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md index 8c279abb34..ebb4ab4087 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md @@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su - `finish`: install the final room. Content added during the batch updates the remaining movement by starting a new segment. -- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation. +- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow. - A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately. - Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change). - The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain. diff --git a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md index 96e36590e7..88d8dc6893 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md @@ -38,7 +38,7 @@ Status: implemented - `finish`:安装最终补白。 批次中途新增的内容按分段规则更新剩余运动。 -- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。 +- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。 - 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。 - 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。 - 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml new file mode 100644 index 0000000000..9714a56a7e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md +/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller: + en: 9105f8148e107f43 + zh: a160e87bb0605da4 +/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem: + en: 5071232d9743096c + zh: b553a626538b3466 +/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision: + en: 1b61d351c4d2bdf1 + zh: d6f8180b30d97b34 +/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered: + en: acc465cfe02b624c + zh: db1d06574f647130 +/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences: + en: 9d8486586d044709 + zh: 24c7249421fd2541 diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md new file mode 100644 index 0000000000..6d1c10ad9f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md @@ -0,0 +1,51 @@ +# Agent Note: Delivering messages from outside an Agent through the Agent controller + +Status: implemented + +English | [中文](2026-10-17-agent-controller-message-delivery.zh.md) + +## Problem + +Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client. + +## Decision + +The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged. + +| Part | Contract | +|---|---| +| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox | +| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake | +| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails | +| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it | +| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled | +| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox | + +Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message. + +`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice. + +The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check. + +Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids. + +The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile. + +## Alternatives considered + +- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract. +- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it. +- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts. +- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing. +- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it. +- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`. + +The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it. + +## Consequences + +- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed. +- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft. +- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced. +- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt. +- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point. diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md new file mode 100644 index 0000000000..5f5d247f7b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md @@ -0,0 +1,51 @@ +# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息 + +Status: implemented + +[English](2026-10-17-agent-controller-message-delivery.md) | 中文 + +## 问题 + +插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。 + +## 决策 + +core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。 + +| 部分 | 约定 | +|---|---| +| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id | +| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 | +| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 | +| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 | +| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 | +| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 | + +只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。 + +`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。 + +Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。 + +请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。 + +Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。 + +## 备选方案 + +- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。 +- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。 +- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。 +- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。 +- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。 +- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。 + +PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。 + +## 影响 + +- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。 +- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。 +- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。 +- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。 +- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.i18n.yaml new file mode 100644 index 0000000000..569f25ed92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md +/agent-note-client-plugin-package-assets-served-by-url: + en: 5505b948511a2d52 + zh: dae985d67e5c5935 +/agent-note-client-plugin-package-assets-served-by-url/problem: + en: 159de908b09780eb + zh: 8d15ca2f15b8846a +/agent-note-client-plugin-package-assets-served-by-url/decision: + en: 8ffcd355ec5e8271 + zh: 2932530bf783e3f7 +/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names: + en: 704267953c123629 + zh: 860997024ad7825e +/agent-note-client-plugin-package-assets-served-by-url/decision/build: + en: 485013b2b6778fa2 + zh: 7e5cb238bb2b8585 +/agent-note-client-plugin-package-assets-served-by-url/decision/host-route: + en: 151b7300f5b27a9f + zh: fae9adff942c2ce8 +/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview: + en: db1c7b2a0b916804 + zh: 0edf4123667d8e2e +/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests: + en: eedff52d04763c50 + zh: 203bfcf81242b8fe +/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration: + en: 1e4d83d0de2d47c9 + zh: dc2c2876c73d3b1c +/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered: + en: 98960e10634ee536 + zh: f0fdb0b0cdf9511c +/agent-note-client-plugin-package-assets-served-by-url/consequences: + en: 898262f39c0243ae + zh: e30f2161f5b669fe +/agent-note-client-plugin-package-assets-served-by-url/testing: + en: 8b6ce91df7c4c3a5 + zh: b149b71cef5be761 diff --git a/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md b/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md new file mode 100644 index 0000000000..672d988ba2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md @@ -0,0 +1,92 @@ +# Agent Note: Client plugin package assets served by URL + +Status: implemented + +English | [中文](2026-10-19-client-plugin-package-assets.zh.md) + +## Problem + +The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style. + +The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image. + +## Decision + +A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset()` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins//assets/`. + +### References and file names + +Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content: + +```text +src/client/assets/running-whale@2x.png +lib/assets/running-whale@2x-06738fa29b2f.png +plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png +``` + +Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `