Merge remote-tracking branch 'origin/master' into fix/focus-ring-brand-primary
Conflicts and how they were resolved: - ui-agent-preset/AgentPresetSection.module.css: master replaced the icon-only tooltip actions (`.iconButton`, `.iconDanger`, `.revealedPath`) with `.cardHelp` + `.helpButton`, which deleted the one declaration this branch edited. Took master's structure and re-applied the branch's change to the two `.cardMain:focus-visible` rings, which master kept unchanged. - ui-theme/README.md and README.zh.md: master documented the separately exported `brand-font.css`; this branch documented `focus.css`. Kept both, with the dynamic entry's sheet list at seven sheets including `focus.css`. `README.i18n.yaml` re-recorded with `verify-translation-pairing --write`.
This commit is contained in:
commit
fae37cd705
3460 files changed
+122758
-47830
No files matched your search
+2
-2
@@ -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/architecture/2026-08-03-per-session-agent-presets.md
|
||||
2026-08-03-per-session-agent-presets.md: 47f2e28c2aac583ce4ca810157e9c7abe58256d5
|
||||
2026-08-03-per-session-agent-presets.zh.md: 88b643ea710364f18ff7c73a8ca0e10de0fbb44c
|
||||
2026-08-03-per-session-agent-presets.md: a1f2ad294edfdaaf2dab2d02e858e4dcb0a0c746
|
||||
2026-08-03-per-session-agent-presets.zh.md: b745c22c91b4017131a32796c085a869a2eabaf0
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: A session's agent is composed from a preset cordis.yml
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-18
|
||||
|
||||
English | [中文](2026-08-03-per-session-agent-presets.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 会话的 agent 由一份 preset cordis.yml 组装而成
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-18
|
||||
|
||||
[English](2026-08-03-per-session-agent-presets.md) | 中文
|
||||
|
||||
+2
-2
@@ -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/bug-fix/2026-09-16-movable-mandatory-update-window.md
|
||||
2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68
|
||||
2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44
|
||||
2026-09-16-movable-mandatory-update-window.md: da996e5b37f990ff773e3ec88de0baed3c4b54f3
|
||||
2026-09-16-movable-mandatory-update-window.zh.md: 15fd4eb2520a39d8259321a747b33c833b56cbb1
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Movable mandatory-update window
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-18
|
||||
|
||||
English | [中文](2026-09-16-movable-mandatory-update-window.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 可移动的强更窗口
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-18
|
||||
|
||||
[English](2026-09-16-movable-mandatory-update-window.md) | 中文
|
||||
|
||||
@@ -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/archived/feature/2026-08-25-unified-task-list.md
|
||||
2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd
|
||||
2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: One session-header task list — jobs joined with activities in the UI projection only
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-03
|
||||
|
||||
English | [中文](2026-08-25-unified-task-list.zh.md)
|
||||
|
||||
Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
|
||||
|
||||
## Problem
|
||||
|
||||
The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy.
|
||||
|
||||
## Decision
|
||||
|
||||
The service planes stay orthogonal; the join happens once, per row, in the UI projection.
|
||||
|
||||
- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log.
|
||||
- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list.
|
||||
- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy.
|
||||
- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then settled rows compacted to one de-emphasized line; each non-empty section carries its own heading (Running / Finished). Rows without an observable activity render as static rows with no expansion affordance.
|
||||
|
||||
Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above.
|
||||
- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway.
|
||||
|
||||
## Refinements from browser acceptance
|
||||
|
||||
Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band:
|
||||
|
||||
- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots.
|
||||
- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists.
|
||||
- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold.
|
||||
- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it.
|
||||
|
||||
All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged.
|
||||
|
||||
## Testing
|
||||
|
||||
Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end.
|
||||
- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note.
|
||||
- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-03
|
||||
|
||||
[English](2026-08-25-unified-task-list.md) | 中文
|
||||
|
||||
Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
|
||||
|
||||
## 问题
|
||||
|
||||
[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。
|
||||
|
||||
## 决策
|
||||
|
||||
服务面保持正交;合并只在 UI 投影层逐行发生一次。
|
||||
|
||||
- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。
|
||||
- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。
|
||||
- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。
|
||||
- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),已结束的行折为降权单行;每个非空分组带自己的标题(进行中 / 已结束)。没有可观察 activity 的行渲染为无展开交互的静态行。
|
||||
|
||||
刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **合并服务层**——因上述游标语义与模型可见性理由否决。
|
||||
- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。
|
||||
|
||||
## 浏览器验收带来的细化
|
||||
|
||||
在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带:
|
||||
|
||||
- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。
|
||||
- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。
|
||||
- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。
|
||||
- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。
|
||||
|
||||
四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。
|
||||
|
||||
## 测试
|
||||
|
||||
组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。
|
||||
|
||||
## 后果
|
||||
|
||||
- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。
|
||||
- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。
|
||||
- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。
|
||||
@@ -160,6 +160,9 @@
|
||||
"architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml": "sha256:43c9128079955e974fe1da3ce1a10779b4f5a25ac3629f92e3b1de847ad45050",
|
||||
"architecture/2026-08-01-packaged-ripgrep-search.md": "sha256:36cf2d6daa654abced690965338aec4628069dc77be3a1da6368a9b097a59ff4",
|
||||
"architecture/2026-08-01-packaged-ripgrep-search.zh.md": "sha256:45b2fe8e0d492eeff313d2ef3c98fd69761b3257eee3476b8bf53312e8b6095f",
|
||||
"architecture/2026-08-03-per-session-agent-presets.i18n.yaml": "sha256:3fe057a67e41f9ce4f5cc8b162e264494b39169588fe9c9e48dcf95fd1b208a1",
|
||||
"architecture/2026-08-03-per-session-agent-presets.md": "sha256:d4c6c6af11f41b57e95ca359e2cbe09780cb0851037998824a5966cab5e12626",
|
||||
"architecture/2026-08-03-per-session-agent-presets.zh.md": "sha256:c91248b2d9cc5c51c77e9d195aca5d51a06684971b1afafc60b2def72248afca",
|
||||
"architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml": "sha256:c1a59c3c04afada633740c808b98e765ccad09220c4c79d8f4207313c9e9aab6",
|
||||
"architecture/2026-08-03-pi-ai-declared-provider-catalog.md": "sha256:fae0c59ece887f7d9344b6254d20cc671894074d53eefffb85dbf45b9104d2b6",
|
||||
"architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md": "sha256:8032cefedd51b292d810051ff32e8825958e6d90b2e523a374294ef2f859ca1e",
|
||||
@@ -751,6 +754,9 @@
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80",
|
||||
"bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2",
|
||||
"bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml": "sha256:e526ce14ae9557e24364e3686778b130090445ef2152d976b7cc93e76eac6401",
|
||||
"bug-fix/2026-09-16-movable-mandatory-update-window.md": "sha256:67546cea0b3de2b3e32957a508dbde097d91dd919fe067c8dbecb2c65b50c366",
|
||||
"bug-fix/2026-09-16-movable-mandatory-update-window.zh.md": "sha256:20a0aade6ee71c89e84d70e23c89bb51e8a38c674d103dd43022feabb8d27be3",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
|
||||
"bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
|
||||
@@ -1375,6 +1381,9 @@
|
||||
"feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml": "sha256:86bd02d56cf95ac465d0f6e326ccf75432ffb9c3db47373ae6a795195cff9ea7",
|
||||
"feature/2026-08-25-loaded-turn-chat-navigation.md": "sha256:0ee1cb6da76b93e467d747aa8ba07697ba648cb213ba52202c51ee1e85dff1a8",
|
||||
"feature/2026-08-25-loaded-turn-chat-navigation.zh.md": "sha256:5d3e567027fc65241856bf9f9cf0d73a20d53214c49bfbc884ab2ddd19503918",
|
||||
"feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33",
|
||||
"feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df",
|
||||
"feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce",
|
||||
"feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml": "sha256:69179f3056c6d5fc35119e83dd7a5af30213502dac3c1f2d4fd43eae96ee2ecf",
|
||||
"feature/2026-08-26-web-trigger-menu-presentation-polish.md": "sha256:c54e3bbe528d6be711541c61e777e6e4ed4fbc1374df97d846b015d3693679f4",
|
||||
"feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md": "sha256:7db49018eebbba539838d734a80fbb330e2b73fea3b343900591a3605c6c03b5",
|
||||
@@ -1774,6 +1783,9 @@
|
||||
"simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml": "sha256:e000606d801984d4ee473779891ffe8c197f64f54b75efd82fb46dc84217cd59",
|
||||
"simplification/2026-08-06-user-bubbles-drop-the-branch-action.md": "sha256:b93ce0d79ee72306d569d80fd81308a2b5704d95df8854b7cbd2f3d898b3f776",
|
||||
"simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md": "sha256:999e3e1252f771c420506a80b7c0d512b70da6770e3e62bb92169180b4cee3e8",
|
||||
"simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml": "sha256:43bba45597b198a94e3cbf8df31794239c521ecf002338636dca2c099acd94e1",
|
||||
"simplification/2026-08-08-copy-only-preset-authoring.md": "sha256:be77d385a3c4db534fbbd5b4a830fcb7f6bcd8401221617859569e2c4d837069",
|
||||
"simplification/2026-08-08-copy-only-preset-authoring.zh.md": "sha256:4c544a08e741c4a273a89dc2a3fda44dffb46af15d0feb8f7bf8efa816bfbd13",
|
||||
"simplification/2026-08-08-remove-cli-demo.i18n.yaml": "sha256:1baa8649e186e159afb03f112059f46f7a35c303a6039374db8bc4ca743d4bdf",
|
||||
"simplification/2026-08-08-remove-cli-demo.md": "sha256:a34ce79b674c67a6a47d99c1fa7141b2ae58e36b99da5989d323d229d1921baa",
|
||||
"simplification/2026-08-08-remove-cli-demo.zh.md": "sha256:96a2a7cc34c7b0abf71b77d666a6349d681fd8a0d9a9c81e30d2aa538d62797a",
|
||||
|
||||
@@ -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/archived/simplification/2026-08-08-copy-only-preset-authoring.md
|
||||
2026-08-08-copy-only-preset-authoring.md: 07f8a6553cf9c70d5289a074b6e6d0d677dd4a28
|
||||
2026-08-08-copy-only-preset-authoring.zh.md: 58757a9774354b116d235f890beb136034f29904
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Copy-only preset authoring, and the way into a preset's files
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-20
|
||||
|
||||
English | [中文](2026-08-08-copy-only-preset-authoring.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-20
|
||||
|
||||
[English](2026-08-08-copy-only-preset-authoring.md) | 中文
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-06-20-generic-long-running-tool-runtime.md
|
||||
2026-06-20-generic-long-running-tool-runtime.md: c386d3b338c94e0e5bbf8e99c2c7a30971ba92b7
|
||||
2026-06-20-generic-long-running-tool-runtime.zh.md: 002514740a15c5961960d20976fc18f7fd8071ff
|
||||
2026-06-20-generic-long-running-tool-runtime.md: 5960eb268656d7e316c763631451db1c6d1c6e49
|
||||
2026-06-20-generic-long-running-tool-runtime.zh.md: ea63ae82962a735b3a96e1803708172a33f83e9d
|
||||
+4
-4
@@ -27,7 +27,7 @@ The literal types live on the [tasks subsystem page](../../../../docs/subsystems
|
||||
|
||||
The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background job admission decision](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md). Its positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count.
|
||||
|
||||
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families.
|
||||
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobView`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families.
|
||||
|
||||
A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', jobId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the job runtime: later cancellation of the producing tool call must not kill the published task. `job_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`.
|
||||
|
||||
@@ -62,7 +62,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow
|
||||
- `read(id, caller?)` for a consuming stream delta or an idempotent final result.
|
||||
- `kill(id, caller?, reason?)` for cancellation.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting.
|
||||
- `onJobDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment.
|
||||
- `events.subscribe(filter, listener)` for effect-scoped observation of registration, progress, output, settlement, and removal, filtered by owner or owner scope, with listener containment.
|
||||
- `attachController(name)` for the task-controller availability fence.
|
||||
|
||||
`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing.
|
||||
@@ -81,7 +81,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is
|
||||
|
||||
The system prompt tells the model to retain job ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md) owns.
|
||||
|
||||
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job <id>` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
|
||||
`dsh-tool-jobs` keeps the delivery ledger: a wait claims the task when it starts (and withdraws if the task outlives the wait) and a `job_kill` claims it when accepted, so a settlement the model already received through a tool result injects no redundant completion notice; teardown settlements and unowned tasks are never announced ([seam consolidation](2026-09-03-jobs-seam-consolidation.md)). Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a view carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job <id>` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
|
||||
|
||||
## Producer opt-in
|
||||
|
||||
@@ -105,7 +105,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup,
|
||||
|
||||
### An immediate abstract task-runtime backend
|
||||
|
||||
The current `JobStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics.
|
||||
The current `JobSpec.run()` contract returns in-process hooks and pushes output through an in-process `JobHandle`, and owners resolve to live `Agent`s in-process. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics.
|
||||
|
||||
### Consumer-owned authorization or cleanup events
|
||||
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ Status: implemented
|
||||
|
||||
进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。
|
||||
|
||||
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
|
||||
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobView`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
|
||||
|
||||
面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', jobId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`job_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。
|
||||
|
||||
@@ -62,7 +62,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访
|
||||
- `read(id, caller?)`:消费式流增量或幂等的最终结果。
|
||||
- `kill(id, caller?, reason?)`:取消。
|
||||
- `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。
|
||||
- `onJobDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。
|
||||
- `events.subscribe(filter, listener)`:effect 作用域内对注册、进度、输出、结算与移除的观察,按所有者或所有者 scope 过滤,具有监听器隔离。
|
||||
- `attachController(name)`:任务控制器可用性防线。
|
||||
|
||||
`wait` 在任务完成时返回终止快照,在等待超时时返回当前快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内发生结算时,不会代表一个实际未收到任何内容的读取方压制完成通知。
|
||||
@@ -81,7 +81,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访
|
||||
|
||||
系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。
|
||||
|
||||
当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job <id>` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
|
||||
`dsh-tool-jobs` 持有投递台账:等待在开始时认领任务(任务比等待活得更久则撤回),`job_kill` 被接受时也认领,因此模型已经通过工具结果收到的结算不会再注入冗余的完成通知;teardown 结算与无主任务从不播报([seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md))。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当视图携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job <id>` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
|
||||
|
||||
## 生产方显式启用
|
||||
|
||||
@@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She
|
||||
|
||||
### 立即抽象任务运行时后端
|
||||
|
||||
当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
|
||||
当前 `JobSpec.run()` 约定返回进程内钩子并经进程内的 `JobHandle` 推送输出,所有者也在进程内解析为存活的 `Agent`。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
|
||||
|
||||
### 由消费方负责授权或清理事件
|
||||
|
||||
|
||||
@@ -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/architecture/2026-07-08-tool-output-spill-files.md
|
||||
2026-07-08-tool-output-spill-files.md: fa6697aad63c97b0ef580fe1ca46e50541e3d682
|
||||
2026-07-08-tool-output-spill-files.zh.md: a995eafc7878b342a2164c5116da1f43ada791a5
|
||||
2026-07-08-tool-output-spill-files.md: e32aec72f423dff33a29d9012c0f71c9cfbd24fb
|
||||
2026-07-08-tool-output-spill-files.zh.md: 2941fc516723e72b5d5670e674ce612f3cf581cc
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### Spill policy
|
||||
|
||||
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
|
||||
|
||||
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
|
||||
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
|
||||
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
|
||||
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
|
||||
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
|
||||
|
||||
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
|
||||
|
||||
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
|
||||
|
||||
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
|
||||
`dsh-spill-policy` retains ordered text/image ends under `maxInlineTokens` after post-execute policy accepts the result. `saveText()` stores the complete result with readable image paths; image bytes remain in attachment storage. The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) owns image projection order, atomic image omission, and route pricing. An omitted budget installs no listeners, recovery failures keep the original result, and model-facing `read` results skip retention to avoid a read/spill loop.
|
||||
|
||||
## Showcase: web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ With `dsh-spill-policy` configured, a large formatted fetch result is automatica
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
This separation is important. `web-fetch-http` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
|
||||
@@ -186,11 +157,11 @@ The local-backend value proposition depends on the existing `read`/`grep` tools
|
||||
|
||||
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
|
||||
|
||||
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
|
||||
The policy can become too large if it starts owning tool-specific semantics. It handles accepted text/image sequences without interpreting tool-specific text. Tool-owned early spill remains future work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineTokens` deployment knob is enough to prove the shape.
|
||||
|
||||
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
|
||||
|
||||
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### spill 策略
|
||||
|
||||
`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
|
||||
|
||||
1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
|
||||
2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
|
||||
3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
|
||||
4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
|
||||
5. 把模型可见结果替换为保留的首尾预览和 spill 引用。
|
||||
|
||||
预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。
|
||||
|
||||
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
|
||||
|
||||
策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
|
||||
`dsh-spill-policy` 在执行后策略接受结果之后,按 `maxInlineTokens` 对文字和图片共同计量并保留首尾。完整结果仍通过 `saveText()` 保存,图片本体留在附件存储,结果文件记录可读取路径。图片投影顺序、整图省略和模型计量由[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)负责。省略配置时不安装监听器,存储失败时保留原结果;`read` 的模型可见结果跳过自动省略,避免重复读取产生循环。
|
||||
|
||||
## 示例:web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ ctx.tools.register(defineTool({
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
这项分离很重要。`web-fetch-http` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
|
||||
@@ -186,11 +157,11 @@ ctx.tools.register(defineTool({
|
||||
|
||||
**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
|
||||
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它处理已接受的图文序列,不解释工具专用的文字语义。由工具负责的提前 spill 仍留作未来工作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineTokens` 部署配置项即可验证该形态。
|
||||
|
||||
**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7260d11c84f8fb07cf6c4691357f92e0d00fd8f3
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 653fb3d9b782d9010f693e4af8ad94475abb9d94
|
||||
+1
-1
@@ -40,7 +40,7 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-07-16-explicit-turn-cancellation.md
|
||||
2026-07-16-explicit-turn-cancellation.md: 09631349226837d7f93112fe5e9c517f2fee099c
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: baf039d49578895d53ffae4c6b88d29b3d1563b6
|
||||
2026-07-16-explicit-turn-cancellation.md: d59accf2dc3f894ffab226e5ddc9d2e746f4d93e
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: 8fb0728eb2bbe8fa01dbefebe7e02aa8c836617a
|
||||
@@ -12,7 +12,7 @@ The [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) inten
|
||||
|
||||
## Decision
|
||||
|
||||
Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work.
|
||||
Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`, and `agent.cancel()` requires one. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. The holder aborts with the caller's own object rather than a detached copy, so a transport reaching that signal can extend it: Node's fetch assigns a `stack` onto the reason it receives. The loop therefore copies the declared fields where the cause becomes durable data. Freezing the cause at `cancel()` is not an alternative — undici performs that assignment from strict-mode code, so a frozen reason makes `fetch` reject with `TypeError: Cannot add property stack` in place of the abort reason. Idle cancellation has no holder to mutate and does not arm later work.
|
||||
|
||||
An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail.
|
||||
|
||||
@@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo
|
||||
|
||||
The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's provider-managed termination and join boundary; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the supported local scope, Job, and fallback mechanics. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
|
||||
|
||||
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
|
||||
`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a typed cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam.
|
||||
|
||||
Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol.
|
||||
|
||||
@@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after
|
||||
|
||||
## Verification
|
||||
|
||||
Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle.
|
||||
Contract tests verify the typed caller union, field copying where the cause becomes durable data, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle.
|
||||
|
||||
Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。
|
||||
Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`,`agent.cancel()` 必须显式传入其一。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。持有者中止时携带的是调用方自己的那个对象,而非一份分离的拷贝,因此能接触到该 signal 的传输层可以向其添加属性:Node 的 fetch 会给它收到的 reason 赋一个 `stack`。所以循环在 cause 成为持久数据的那一步拷贝已声明字段。在 `cancel()` 处冻结 cause 并非可选方案——undici 是在严格模式代码中执行该赋值的,被冻结的 reason 会让 `fetch` 以 `TypeError: Cannot add property stack` 取代中止原因而拒绝。空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。
|
||||
|
||||
正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。
|
||||
|
||||
@@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它
|
||||
|
||||
显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](../../archived/architecture/2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器由提供方管理的终止与等待边界;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责受支持本地路径上的 scope、Job 与 fallback 机制。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
|
||||
|
||||
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
|
||||
`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带类型化的 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。
|
||||
|
||||
Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。
|
||||
|
||||
@@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时
|
||||
|
||||
## 验证
|
||||
|
||||
约定测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
|
||||
约定测试验证类型化调用方联合类型、在 cause 成为持久数据处拷贝字段、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。
|
||||
|
||||
发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-07-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1
|
||||
2026-07-23-client-plugin-loading-model.md: b8c23108a7a1cf3a04ded373d8705bae626c70c2
|
||||
2026-07-23-client-plugin-loading-model.zh.md: efaea843120e5b023bbc8f59663ea47468944c71
|
||||
@@ -46,7 +46,7 @@ A `require.async("./client.<name>.js")` call requests the exact revisioned `/plu
|
||||
|
||||
The shared tsdown preset emits a map for every plugin entry and chunk and rewrites first-party source paths into the browser-resolvable repository form `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Combo generation strips each local debug directive, records its generated-line offset, resolves every authored source against the original per-plugin map URL, and emits an Indexed Source Map v3. An authored map supplies its section; otherwise an identity section embeds the generated bundle and uses the packer's `sourceURL` as its source name when present. The absolute combo map URL mirrors the script resource list by changing every `client.js` suffix to `client.js.map`, so `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` points to `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`. A chunk script likewise points to its own map URL. Source-map files are read and combined only when those map URLs receive `GET`; `HEAD` materializes neither script nor map bodies. The Vite shell also emits source maps, letting shell code and externally loaded plugins map stacks and performance profiles back to TypeScript/TSX.
|
||||
|
||||
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial row revisions are opaque process nonces rather than content hashes; they keep the snapshotted one-resource response immutable without hashing every plugin at startup. The shared preset stamps the entry after every package output is written. After the watcher observes that completed-build stamp, `rebuilt(id)` hashes the entry bytes together with its timestamp and publishes the resulting revision; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
|
||||
The graph retains each row's revisioned one-resource combo URL for HMR and adds a revisioned descriptor for every startup combo request; several descriptors may carry the same scheduling phase. Initial publication and `rebuilt(id)` use the same hash of entry mtime, ctime, and size; artifact contents never enter the hash. Ctime distinguishes rewrites that preserve mtime and size. A Host restart over unchanged artifacts therefore retains every row revision, so the reconnecting SSE snapshot does not trigger browser plugin replacement. The shared preset stamps the entry after every package output is written; a chunk-only build therefore advances the owner revision without a Host-side sibling scan. Startup combo revisions derive from the ordered row revisions. Script bodies are assembled on their first `GET`; source-map files are read and combined separately on their first map `GET`. `HEAD` materializes neither body. Versioned scripts and maps use immutable caching, and unrelated graph recomposition retains already-materialized chunk responses at the same revision. The Host serves only exact generated URLs; stale revisions and unadvertised resource lists return 404 instead of aliasing different bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
|
||||
|
||||
### The loading flow, end to end
|
||||
|
||||
@@ -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 bundle re-hashing 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 receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, 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, 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -80,7 +80,7 @@ The Host SSE adapter forwards existing graph-change notifications and sends the
|
||||
|
||||
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
|
||||
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance forces a new revision even with identical metadata. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime or size delta, or a dirty row, `rebuilt(id)` derives the revision from the entry bytes and completed-build timestamp. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the entry stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids redundant reads when installing the watcher and avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and an entry missing at poll time keeps its row dirty so reappearance retries publication even when mtime and size match the baseline. The shared tsdown preset stamps `client.js` after every sibling output has been written; on an entry mtime, ctime, or size delta, or a dirty row, `rebuilt(id)` derives the revision from those three metadata values. It reads the new bundle snapshot only if the revision changes. A chunk-only edit therefore changes the revision, and a partial write followed by the completion stamp supplies a later stat change that self-heals. When the `rev` changes, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one entry stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any process using the shared Client tsdown preset — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share no notification protocol.
|
||||
|
||||
On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation:
|
||||
|
||||
@@ -120,4 +120,4 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
|
||||
| Full ctx-ification now (React and libraries via services, no module table) | Static UI libraries still expose synchronous values, so removing the table would leave those imports without a shared identity |
|
||||
| Eager instantiation with a frozen table | Runs bundle side effects at script arrival; lazy registration keeps execution at Cordis import while recursive `require` materializes registered requests |
|
||||
| Fetch response text, then inject an inline `<script>` | Makes the module system buffer the complete source and maintain separate fetch/execute paths; dynamic source execution also breaks the browser-native association among the network resource, source map, and profile |
|
||||
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (re-hash on every stat change) that once justified pushing |
|
||||
| Builder-push rebuild channel (`POST /plugins/rebuilt` from the orchestrator's `onSuccess`) | Couples reload to one blessed builder process and a second wire protocol; the webserver already holds every bundle path, and stat polling covers the torn-write race (publication on every stat change) that once justified pushing |
|
||||
@@ -46,7 +46,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
|
||||
|
||||
共享 tsdown 预设为每个插件入口与 chunk 产出 map,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 combo map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。chunk 脚本同样指向自己的 map URL。只有在这些 map URL 收到 `GET` 后,source map 文件才会被读取和组合;`HEAD` 不会物化脚本或 map body。Vite 壳同样产出 sourcemap,使壳代码与经外部加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
|
||||
|
||||
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。共享预设会在每个包输出写完后标记入口。watcher 观察到该构建完成标记后,`rebuilt(id)` 会把入口字节与其时间戳一起哈希并发布所得 revision;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
|
||||
图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。首次发布与 `rebuilt(id)` 使用相同的入口 mtime、ctime 和大小哈希;产物内容不参与求哈希。ctime 用于区分保留 mtime 和大小的重写。Host 重启时若产物未变,每个 row 的 revision 都保持不变,因此重连后的 SSE 快照不会触发浏览器插件替换。共享预设会在每个包输出写完后标记入口;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
|
||||
|
||||
### 装载流程,端到端
|
||||
|
||||
@@ -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 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
|
||||
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 注入行。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
@@ -80,7 +80,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
|
||||
|
||||
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
|
||||
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制生成新 revision。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime 或 size 变化、或 row 处于标脏状态时,`rebuilt(id)` 从入口字节与构建完成时间戳派生 revision。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这既避免安装 watcher 时重复读取,也避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使 mtime 和大小与基线相同也重试发布。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime、ctime 或大小变化、或 row 处于标脏状态时,`rebuilt(id)` 从这三个元数据值派生 revision。只有 revision 变化时,它才读取新的 bundle 快照。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议。
|
||||
|
||||
浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
|
||||
|
||||
@@ -120,4 +120,4 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块
|
||||
| 现在就彻底 ctx 化(React 与库全走服务,不设模块表) | 静态 UI 库仍暴露同步实体,因此删除模块表会让这些 import 失去共享身份 |
|
||||
| 冻结表 + 到达即实例化 | 会在 script 到达时执行 bundle 副作用;惰性登记把执行推迟到 Cordis import,并由递归 `require` 物化已登记请求 |
|
||||
| fetch 响应文本后注入内联 `<script>` | 模块系统必须缓冲整份源码并维护 fetch/execute 两条路径;动态源码执行也切断浏览器网络资源、sourcemap 与 profile 的原生关联 |
|
||||
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即重哈希)已兜住当年为推送辩护的撕裂写竞态 |
|
||||
| 构建器推送重建通道(编排器在 `onSuccess` 里 POST `/plugins/rebuilt`) | 把重载耦合到一个钦定的构建器进程和第二套 wire 协议;webserver 本就握有每个 bundle 路径,stat 轮询(每次 stat 变化即发布)已兜住当年为推送辩护的撕裂写竞态 |
|
||||
+2
-2
@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 32be87f3a0a4f8da92100ec7351af788862532f7
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.md: 76561bc0e330b3e3a26607569ae327217d0a0b67
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d5d92b2ca53ba78929dd8591a6ded7028e4fed24
|
||||
+1
-1
@@ -26,7 +26,7 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r
|
||||
Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):
|
||||
|
||||
- Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id.
|
||||
- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity.
|
||||
- A manual Workspace pick or successful [first-use startup initialization](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity.
|
||||
- "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text).
|
||||
- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live.
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插
|
||||
host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):
|
||||
|
||||
- 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
|
||||
- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。
|
||||
- 手动选定 Workspace 或完成[首次使用的启动初始化](../feature/2026-09-20-default-workspace.zh.md) 后,cwd 已确定,client 再调 `session.create({workspaceId})`,拿到完整实体。
|
||||
- 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
|
||||
- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-08-02-typert-remote-method-calls.md
|
||||
2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf
|
||||
2026-08-02-typert-remote-method-calls.md: 2a12aaf78f638d8af786d2ca817a3fee07a2c614
|
||||
2026-08-02-typert-remote-method-calls.zh.md: bf4a7b0179b4dc878a0a53c7076c02e6f1bf0273
|
||||
@@ -31,8 +31,8 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
|
||||
| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
|
||||
| Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
|
||||
| Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, and invokes methods |
|
||||
| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments |
|
||||
| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, physical serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel |
|
||||
| API Gateway's Client face | `ctx.remote`, `ctx.remote.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` child Service, and delegates canonical calls to `ctx.connection.rpc` |
|
||||
| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations |
|
||||
| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers |
|
||||
@@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re
|
||||
|
||||
Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition.
|
||||
|
||||
Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
|
||||
Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and JSON success results. Binary success results use their generated codec after multipart decoding, as specified by [binary Remote transfer](2026-09-17-workspace-file-binary-transfer.md). If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
|
||||
|
||||
Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names.
|
||||
|
||||
@@ -514,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma
|
||||
|
||||
Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error.
|
||||
|
||||
Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms.
|
||||
Generated Host and Client artifacts carry matching Zod factories; Client Remote materializes only binary result schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms.
|
||||
|
||||
A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime.
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
|
||||
| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
|
||||
| Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
|
||||
| Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver 并调用方法 |
|
||||
| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 |
|
||||
| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、物理序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route |
|
||||
| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
|
||||
| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
|
||||
| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider |
|
||||
@@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/
|
||||
|
||||
Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。
|
||||
|
||||
Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
|
||||
Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与身份 codec 校验输入。Client Remote 信任生成的 TypeScript 参数与 JSON 成功结果。二进制成功结果在 multipart 解码后执行生成的结果 codec,具体由[二进制 Remote 传输](2026-09-17-workspace-file-binary-transfer.zh.md)规定。复杂类型若无法生成严格 codec,LIB 构建失败,不降级为 `unknown` 或未校验 JSON。
|
||||
|
||||
Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。
|
||||
|
||||
@@ -514,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
|
||||
|
||||
类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。
|
||||
|
||||
生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。
|
||||
生成的 Host 与 Client 产物携带匹配的 Zod factory;Client Remote 仅实例化二进制结果 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。
|
||||
|
||||
消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-08-04-configuration-source-ownership.md
|
||||
2026-08-04-configuration-source-ownership.md: 6dea1f08a9f2a0319f894a504082ff70bbe1118f
|
||||
2026-08-04-configuration-source-ownership.zh.md: 1ccf2c266c3e52f2f10d3bceb3b1505ae6266b15
|
||||
2026-08-04-configuration-source-ownership.md: b8c146ed76e500fcde3affd92bc5188b83083223
|
||||
2026-08-04-configuration-source-ownership.zh.md: 22afc15aec97a40829655fcb8179ad90a369bf0c
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-configuration-source-ownership.zh.md)
|
||||
|
||||
The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md). Credential ordering and environment restrictions remain current.
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` had just [become an ordinary environment layer](../../archived/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md), which left the harness resolving user-facing values from a flattened `process.env` that could no longer say where a value came from. Three consequences followed.
|
||||
|
||||
@@ -4,6 +4,8 @@ Status: implemented
|
||||
|
||||
[English](2026-08-04-configuration-source-ownership.md) | 中文
|
||||
|
||||
下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代。凭据优先级与环境限制仍然适用。
|
||||
|
||||
## Problem
|
||||
|
||||
`$DSH_HOME/.env` 刚刚[变成普通环境层](../../archived/architecture/2026-08-04-credentials-yaml-and-user-environment-layer.md),这使得 harness 解析面向用户的值时面对的是一个压平的 `process.env`,再也说不清某个值来自哪里。由此产生三个后果。
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-08-10-host-plane-ownership-after-presets.md
|
||||
2026-08-10-host-plane-ownership-after-presets.md: 78410368cdc66b14fe478a4de9f0ae7a1cbefac5
|
||||
2026-08-10-host-plane-ownership-after-presets.zh.md: 862b793fab23b209f6f43b2e8045ad7330c30ba0
|
||||
2026-08-10-host-plane-ownership-after-presets.md: daf48f27c2228b71319d6f5410e6cabe3bc02b39
|
||||
2026-08-10-host-plane-ownership-after-presets.zh.md: 56babf5e715584b4ee061a334158d46e59b0d350
|
||||
+6
-6
@@ -6,7 +6,7 @@ English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)).
|
||||
[Per-session agent presets](2026-09-18-declarative-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)).
|
||||
|
||||
Two more readers were still on the wrong side of that line.
|
||||
|
||||
@@ -18,15 +18,15 @@ Nothing named an agent that joined no preset. The join is a scope-parent link; w
|
||||
|
||||
**The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compaction-basic` and `tool-result-pruner` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process.
|
||||
|
||||
**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range.
|
||||
**An unjoined agent fails at model use.** The invariant companion checks `system-prompt/assemble`, because a bare Agent can legally be bound later through `recompose`. Host-only and cold-scope prompt reads have no Agent and remain outside this check.
|
||||
|
||||
Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md)).
|
||||
Projection key presence is not a per-session capability signal ([session projection](../../../../packages/session/session-projection/README.md)). Temporary plugins mounted through `cordis_mount` belong to the composition rather than the invoking session ([tool Cordis](../../../../packages/extensions/tool-cordis/README.md)). [Declarative presets](2026-09-18-declarative-agent-presets.md) own revision retention and reclaim retired trees after their final reference releases.
|
||||
|
||||
## Testing
|
||||
|
||||
`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units.
|
||||
`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any Agent is created — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units.
|
||||
|
||||
`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass.
|
||||
`packages/preset/agent-preset-registry/tests/invariant.spec.ts` rejects an unjoined Agent's assembly and accepts a joined Agent, a Host read and a cold-scope read.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -42,4 +42,4 @@ Three limits stay open and are recorded where they bite rather than fixed here:
|
||||
|
||||
The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting.
|
||||
|
||||
The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one.
|
||||
The invariant reaches compositions loading `dsh-invariants`. Production entry points must compose Agents explicitly; installing the registry alone does not bind them.
|
||||
+6
-6
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[逐会话 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。
|
||||
[逐会话 agent preset](2026-09-18-declarative-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。
|
||||
|
||||
还有两个读取点仍站在这条线的错误一侧。
|
||||
|
||||
@@ -18,15 +18,15 @@ Status: implemented
|
||||
|
||||
**meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compaction-basic` 与 `tool-result-pruner` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。
|
||||
|
||||
**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。
|
||||
**未加入的 Agent 在使用模型时失败。** 不变量 companion 检查 `system-prompt/assemble`,因为未绑定的 Agent 可以合法地稍后通过 `recompose` 绑定。纯 Host 和历史 scope 提示词读取没有 Agent,不属于此检查范围。
|
||||
|
||||
有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.zh.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.zh.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md))。
|
||||
投影 key 是否存在不能代表逐会话能力([会话投影](../../../../packages/session/session-projection/README.zh.md))。通过 `cordis_mount` 挂载的临时插件属于组装,而非调用会话([Cordis 工具](../../../../packages/extensions/tool-cordis/README.zh.md))。[声明式 preset](2026-09-18-declarative-agent-presets.zh.md) 负责代际保留,在最后一个引用释放后回收退役树。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。
|
||||
`apps/cli/tests/web-agent-presets.e2e.ts` 在创建任何 Agent **之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。
|
||||
|
||||
`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。
|
||||
`packages/preset/agent-preset-registry/tests/invariant.spec.ts` 拒绝未加入的 Agent 组装,并接受已加入的 Agent、Host 读取和历史 scope 读取。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -42,4 +42,4 @@ Status: implemented
|
||||
|
||||
context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。
|
||||
|
||||
那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。
|
||||
不变量覆盖加载了 `dsh-invariants` 的组装。生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。
|
||||
@@ -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/architecture/2026-08-10-remote-event-delivery.md
|
||||
2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9
|
||||
2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07
|
||||
2026-08-10-remote-event-delivery.md: 3eaa26fb92c40b3a20cab11d4522bcf68b8a207f
|
||||
2026-08-10-remote-event-delivery.zh.md: d41589f1b800da98342210d2e429a2b67b6c61f2
|
||||
@@ -195,3 +195,7 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp
|
||||
- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending.
|
||||
- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee.
|
||||
- **The package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains.
|
||||
|
||||
## Related
|
||||
|
||||
- [Remote duplex stream](2026-09-19-remote-duplex-stream.md): every Remote stream carries a Client uplink on the same logical stream; the duplex `$events` listed under its Deferred section replaces the unary `$events/result` path once it lands.
|
||||
@@ -197,3 +197,7 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续
|
||||
- **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。
|
||||
- **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。
|
||||
- **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md):所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工 `$events` 落地后取代 unary `$events/result` 路径。
|
||||
+2
-2
@@ -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/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: a985e1c91a1ccdb9c4aab0bc519974f9b6fafd4a
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 17ace865477d4fae3650b56d6725be07c7bd0e0f
|
||||
+2
@@ -75,6 +75,8 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations.
|
||||
|
||||
Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk/<release-id>/`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse.
|
||||
|
||||
## Security and release policy
|
||||
|
||||
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
|
||||
|
||||
+2
@@ -75,6 +75,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。
|
||||
|
||||
测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk/<release-id>/`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。
|
||||
|
||||
## 安全与发布策略
|
||||
|
||||
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-08-31-released-session-format-migrations.md
|
||||
2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415
|
||||
2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61
|
||||
2026-08-31-released-session-format-migrations.md: 00672af920cf8369022b4274c1c50552f5407a7e
|
||||
2026-08-31-released-session-format-migrations.zh.md: 9404d8f454b7c17be69ae064c405c4dcc0794f88
|
||||
+3
-1
@@ -89,6 +89,8 @@ Each released codec creates a row decoder with explicit `strict` or `recoverable
|
||||
|
||||
The v0-to-v1 edge preserves logical headers, sequence numbers, references, timestamps, and payloads except for bounded released-v0 normalizations. It translates the retired `steering/message` and `compact/*` event names, accepts a released `llm/retry` after its matching `step/end`, deterministically supplies a missing `llm/retry.retryId` per turn/step/provider/policy chain, and supplies one deterministic `compactionId` across a legacy compaction group that omitted it. The v1-to-v2 edge owns attempt folding and reference remapping, and emits only settled v2 events. It splits a legacy goal-sourced user message into `goal/change` plus the original model-visible message. It also inserts an interrupted `turn/end` for the bounded released restart in which an open turn with no open step is followed by a non-empty `next-turn` inbox splice and the next numbered `turn/start`.
|
||||
|
||||
The V3-to-V4 edge applies the same bounded restart repair to logs written directly as V3. Keeping this repair in the incoming edge preserves [native V4 lifecycle validation](2026-09-17-native-v4-read-validation.md); accepting repeated starts there would admit producer corruption in new logs. Insertions require local-reference and inherited-cut remapping, while captured generations retain their original coordinates. The [format specification](../../../../packages/session/session-format-v3-to-v4/README.md#sequence-references) owns the exact evidence and fields.
|
||||
|
||||
The catalog exposes one `createRestore()` operation for production, Worker, fixture, and replay callers. Recovery policy and final validation policy are chosen once at restore creation. Historical production uses recoverable source parsing with transformed-current validation; this validates the released current result after migration, while input that is already current receives only codec validation. Worker and fixture verification use strict parsing with full installed current restoration. A migration-stage or transformed-current validation refusal remains `SessionFormatUnsupportedMigrationError`; physical decoding failures remain corruption. Test support keeps only fixture-specific token and envelope materialization.
|
||||
|
||||
### JSONL integration
|
||||
@@ -131,7 +133,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
|
||||
|
||||
### Parent catalog prerequisites
|
||||
|
||||
V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
|
||||
Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md).
|
||||
|
||||
Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged.
|
||||
|
||||
|
||||
+3
-1
@@ -89,6 +89,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
v0-to-v1 除了有限的 released-v0 归一化外,会保留逻辑 header、seq、引用、时间戳与 payload。它转换已移除的 `steering/message` 与 `compact/*` 事件名称,接受出现在对应 `step/end` 之后的已发布 `llm/retry`,按 turn/step/provider/policy chain 为缺失的 `llm/retry.retryId` 确定性补值,并为省略 id 的旧 compaction group 确定性补充同一个 `compactionId`。v1-to-v2 负责 attempt folding 与引用重写,并且只 emit 已结算的 v2 event。它会把旧的 goal 来源 user message 拆成 `goal/change` 与原本的模型可见 message。它还会为一种有限的已发布 restart 插入 interrupted `turn/end`:一个没有 open step 的 open turn 后出现非空 `next-turn` inbox splice,随后直接开始编号连续的下一轮。
|
||||
|
||||
V3-to-V4 边对直接写成 V3 的日志应用同样的有限 restart 修复。将修复保留在迁入边,可以维持[原生 V4 生命周期校验](2026-09-17-native-v4-read-validation.zh.md);若在那里接受重复 start,新日志也会允许生产者损坏。插入事件要求重映射本地引用与继承截点,而捕获代际保留原坐标。[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#sequence-references)负责定义具体证据和字段。
|
||||
|
||||
Catalog 为 production、Worker、fixture 与 replay 暴露同一个 `createRestore()`。Recovery policy 与最终 validation policy 在 restore 创建时一次确定。Historical production 使用 recoverable source parsing 与 transformed-current validation;这种策略会在迁移后校验已发布 current 结果,而已经是 current 的输入只接受 codec 校验。Worker 与 fixture verification 使用 strict parsing 与已安装 current 格式的完整 restoration。Migration stage 或 transformed-current validation 的拒绝会保持为 `SessionFormatUnsupportedMigrationError`;物理解码失败仍是 corruption。Test support 只保留 fixture 自身需要的 token 和 envelope materialization。
|
||||
|
||||
### JSONL 串联
|
||||
@@ -131,7 +133,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
|
||||
|
||||
### 父目录前置事实
|
||||
|
||||
V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
|
||||
本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。
|
||||
|
||||
原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。
|
||||
|
||||
|
||||
+6
@@ -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-09-01-jobs-absorb-activity-record.md
|
||||
2026-09-01-jobs-absorb-activity-record.md: 508608f7eb694bf131bad9a25e856d32c7079559
|
||||
2026-09-01-jobs-absorb-activity-record.zh.md: 2c157d951042f1cd6f8a3a66129773bc99cd5b02
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed
|
||||
|
||||
Status: implemented
|
||||
|
||||
Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring and direct operations on `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds.
|
||||
|
||||
English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only).
|
||||
|
||||
The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split.
|
||||
|
||||
## Decision
|
||||
|
||||
`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed.
|
||||
|
||||
- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops.
|
||||
- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented.
|
||||
- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design.
|
||||
- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it.
|
||||
- **The wire splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.follow({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply.
|
||||
- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` is present. The two-roster join is deleted.
|
||||
- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`.
|
||||
- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream.
|
||||
- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family.
|
||||
|
||||
The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched.
|
||||
|
||||
## Review corrections
|
||||
|
||||
Review of the merged design settled five points that the sections above leave implicit:
|
||||
|
||||
- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures.
|
||||
- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR.
|
||||
- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor.
|
||||
- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need.
|
||||
- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped.
|
||||
- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除
|
||||
|
||||
Status: implemented
|
||||
|
||||
Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环与 `ctx.jobs` 上的直接操作——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。
|
||||
|
||||
[English](2026-09-01-jobs-absorb-activity-record.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。
|
||||
|
||||
唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。
|
||||
|
||||
## 决策
|
||||
|
||||
`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。
|
||||
|
||||
- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。
|
||||
- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。
|
||||
- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。
|
||||
- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。
|
||||
- **wire 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.follow({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。
|
||||
- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 join 被删除。
|
||||
- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。
|
||||
- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。
|
||||
- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。
|
||||
|
||||
[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。
|
||||
|
||||
## 评审修正
|
||||
|
||||
对已合并设计的评审确定了上文各节未言明的五点:
|
||||
|
||||
- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。
|
||||
- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。
|
||||
- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。
|
||||
- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。
|
||||
- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。
|
||||
- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。
|
||||
|
||||
## 后果
|
||||
|
||||
每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。
|
||||
+3
-3
@@ -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 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md
|
||||
2026-08-08-copy-only-preset-authoring.md: 13d0bfc7c49d4b97b6c444fe1ff6c6455fef53ec
|
||||
2026-08-08-copy-only-preset-authoring.zh.md: 2b93ac1b81ddb2461308baffb3b6563fe240f9d5
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md
|
||||
2026-09-03-jobs-seam-consolidation.md: 2742ff1a80ec4cc4d8e5948f63d306abb9753733
|
||||
2026-09-03-jobs-seam-consolidation.zh.md: 1fa61919a27362ce42419b57e0960e9be86ea07e
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: One output ring, one projection, one event stream for background jobs
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-03-jobs-seam-consolidation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history.
|
||||
|
||||
## Decision
|
||||
|
||||
- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once.
|
||||
- **Two cursors, one storage.** `JobRegistry.read(id)` is the stateful model cursor; `JobRegistry.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did.
|
||||
- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in.
|
||||
- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`.
|
||||
- **Direct caller-scoped operations.** `JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` accept the caller session on each call. Omitting the caller permits only unowned jobs. The output ring and browser streams do not require a separate caller-bound operation object.
|
||||
- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total.
|
||||
- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement.
|
||||
- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.list({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.follow`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind.
|
||||
|
||||
`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection.
|
||||
- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed.
|
||||
- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`).
|
||||
- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.
|
||||
|
||||
Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The `@deepseek-ai/dsh-jobs/invariant` companion checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read, which is the cross-observation the [companion rule](../simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires; the earlier single-view field checks are gone. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:后台 job 的一个输出环、一个投影、一条事件流
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-03-jobs-seam-consolidation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。
|
||||
|
||||
## 决策
|
||||
|
||||
- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。
|
||||
- **两个游标,一份存储。** `JobRegistry.read(id)` 是有状态的模型游标;`JobRegistry.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。
|
||||
- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。
|
||||
- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。
|
||||
- **直接传入调用方的操作。** `JobRegistry.list`、`get`、`read`、`readAt`、`kill` 与 `wait` 在每次调用时接收调用方会话。省略调用方时仅允许访问无主 job。输出环与浏览器流不需要独立的调用方操作对象。
|
||||
- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。
|
||||
- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。
|
||||
- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.follow` 旁拥有 `job.list({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。
|
||||
|
||||
`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。
|
||||
- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。
|
||||
- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。
|
||||
- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。
|
||||
|
||||
## 后果
|
||||
|
||||
模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。
|
||||
|
||||
合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。`@deepseek-ai/dsh-jobs/invariant` 伴生插件校验每个 job 的事件协议(先 `registered`、恰一次结算、最后 `removed`)以及每个通告的投影与注册表自身读取的一致性,这正是[伴生插件规则](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)要求的交叉观察;先前只针对单个视图的字段校验已删除。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。
|
||||
@@ -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/architecture/2026-09-05-workspace-files-service.md
|
||||
2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563
|
||||
2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715
|
||||
2026-09-05-workspace-files-service.md: 65bebf18175d9a94452c92cd36ada3c5ef267ce5
|
||||
2026-09-05-workspace-files-service.zh.md: b4ca420042dc702f9bc1eaf5bb09f2667a19322d
|
||||
@@ -25,15 +25,13 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's
|
||||
|
||||
`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.
|
||||
|
||||
### The `workspaceFiles` Remote namespace
|
||||
### The `workspaceFiles` file operations
|
||||
|
||||
Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them:
|
||||
Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gateway lookup resolves Session identity. It reads the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The five signatures, as `src/index.ts` declares them:
|
||||
|
||||
```ts ignore-check
|
||||
@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
|
||||
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
|
||||
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
|
||||
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
|
||||
@@ -41,20 +39,20 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from
|
||||
|
||||
- **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
|
||||
- **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream.
|
||||
- **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`.
|
||||
- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics.
|
||||
- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding.
|
||||
- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics.
|
||||
- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered.
|
||||
- **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation.
|
||||
|
||||
### Paths on the wire
|
||||
|
||||
Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd.
|
||||
Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd; `readBytes` with `baseFile` resolves the target from that file's directory.
|
||||
|
||||
`version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end.
|
||||
|
||||
### File checks and workspace containment
|
||||
|
||||
`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
|
||||
`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
|
||||
|
||||
1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
|
||||
2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
|
||||
@@ -73,7 +71,7 @@ Each failure is one `RemoteError` code with typed details, declared beside the t
|
||||
| `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` |
|
||||
| `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` |
|
||||
| `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` |
|
||||
| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` |
|
||||
| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` |
|
||||
@@ -109,7 +107,7 @@ The Client export registers one `ResourceProvider<'file'>` into `ctx.resources`
|
||||
|
||||
### Related notes
|
||||
|
||||
The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness.
|
||||
The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource://<type>/…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -117,7 +115,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
|
||||
|
||||
**A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries.
|
||||
|
||||
**Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises.
|
||||
**Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe.
|
||||
|
||||
**Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations.
|
||||
|
||||
@@ -138,14 +136,14 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
|
||||
- Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version.
|
||||
- File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
|
||||
- The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action.
|
||||
- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats.
|
||||
- Office authorization probes use `readBytes` with a one-byte `range`; Document Preview uses the same method without `range` for complete-file formats.
|
||||
|
||||
## Testing
|
||||
|
||||
Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
|
||||
Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
|
||||
|
||||
## Deferred
|
||||
|
||||
- A bound on a `changes` generation's queue.
|
||||
- The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only.
|
||||
- Write and search operations; the service is read-only.
|
||||
- Scopes other than `session` in the file address; the grammar leaves room, the provider serves one.
|
||||
@@ -25,15 +25,13 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
|
||||
|
||||
`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。
|
||||
|
||||
### `workspaceFiles` Remote 命名空间
|
||||
### `workspaceFiles` 文件操作
|
||||
|
||||
每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明:
|
||||
每个 Host 方法首参都是 `WorkspaceFileScope`。`workspaceFileScope` Gateway lookup 解析 Session 身份。它优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。五个签名照 `src/index.ts` 的声明:
|
||||
|
||||
```ts ignore-check
|
||||
@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
|
||||
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
|
||||
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
|
||||
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
|
||||
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
|
||||
@@ -41,20 +39,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
|
||||
|
||||
- **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
|
||||
- **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。
|
||||
- **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。
|
||||
- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。
|
||||
- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。
|
||||
- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。
|
||||
- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。
|
||||
- **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
|
||||
|
||||
### 线路上的路径
|
||||
|
||||
离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。
|
||||
离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd;带 `baseFile` 的 `readBytes` 从基准文件所在目录解析目标。
|
||||
|
||||
`version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。
|
||||
|
||||
### 文件检查与工作区包含
|
||||
|
||||
`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
|
||||
`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
|
||||
|
||||
1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
|
||||
2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
|
||||
@@ -73,7 +71,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
|
||||
| `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` |
|
||||
| `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` |
|
||||
| `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` |
|
||||
| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` |
|
||||
| `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` |
|
||||
| `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` |
|
||||
@@ -109,7 +107,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
|
||||
|
||||
### 相关记录
|
||||
|
||||
[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。
|
||||
[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource://<type>/…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -117,7 +115,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
|
||||
|
||||
**带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。
|
||||
|
||||
**经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。
|
||||
**提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。
|
||||
|
||||
**文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。
|
||||
|
||||
@@ -138,14 +136,14 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
|
||||
- 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。
|
||||
- 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
|
||||
- `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。
|
||||
- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。
|
||||
- Office 授权探测使用带单字节 `range` 的 `readBytes`;Document Preview 对整文件格式使用同一方法并省略 `range`。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
|
||||
`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
|
||||
|
||||
## Deferred
|
||||
|
||||
- 给 `changes` 一代流的队列加上限。
|
||||
- `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。
|
||||
- 写入和搜索操作;本服务只读。
|
||||
- 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。
|
||||
+6
@@ -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-09-08-desktop-welcome-window-material.md
|
||||
2026-09-08-desktop-welcome-window-material.md: 4ee5f6d2934378b06754c4a8f134a4ca07a80840
|
||||
2026-09-08-desktop-welcome-window-material.zh.md: 1689af8481925c787c1d2412173fbd7d8c2f4b64
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Desktop welcome window material
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-08-desktop-welcome-window-material.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The desktop welcome design blurs the desktop behind its entire window. Chromium backdrop filters only sample content inside the renderer. Credential setup also needs an explicit startup owner so a native skip is not followed by a second Web dialog.
|
||||
|
||||
## Decision
|
||||
|
||||
The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its independently bundled React renderer supplies the entry, account sign-in states and API-key form. It shares the `StateDot` loading component with the Web UI without booting the Web plugin graph. The Desktop build emits local JavaScript and CSS under `lib/welcome`, included in packaged applications; the document keeps its network-denying content security policy. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics.
|
||||
|
||||
Cold startup opens the entry when neither an account credential nor a model API key is stored. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. Startup reads account presence and API-key configuration independently. Account sign-in enters the workspace; sign-out reopens the welcome window only when no model API key remains configured.
|
||||
|
||||
The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins.
|
||||
|
||||
Desktop reads the shared `locale.preference` before opening the welcome window. The Client awaits an isolated preload read of that preference and the same OS language order before mounting, then reports resolved locale changes to the shell. Only an explicit Settings selection writes the preference; automatic detection stays provisional. The shell ships English and Simplified Chinese dictionaries, so a Client-only language pack falls back to a supported OS language in native windows.
|
||||
|
||||
The welcome flow shares the Desktop backend controller with startup recovery. The main recovery document loads offscreen while the Host starts; a failure reveals it, and successful startup chooses the welcome window or workspace after reading credentials and locale. Recovery actions retain the controller’s serialized retries and awaited child shutdown. The application preload exposes recovery controls only to shell documents and the locale bridge only to application documents.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect.
|
||||
|
||||
**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials.
|
||||
|
||||
**Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner.
|
||||
|
||||
**Drawing replacement traffic lights.** Native controls preserve platform window behavior and accessibility. Their dimensions and outer window corners follow the OS rather than reproducing Figma geometry exactly.
|
||||
|
||||
**Separate preview callbacks.** A visual-only skip cannot demonstrate entry into the workspace and can hide broken startup dependencies. The preview uses the same callbacks and Host as the product.
|
||||
|
||||
## Consequences
|
||||
|
||||
Native blur strength and font fallback vary by system. macOS uses menu vibrancy with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. Native materials own the blur strength. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out preserves independently configured API keys. Onboarding presentation produces no Session events.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 桌面欢迎窗口材质
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-08-desktop-welcome-window-material.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
桌面欢迎界面的设计要求整个窗口模糊其背后的桌面。Chromium 背景滤镜只能采样渲染器内部的内容。凭证配置还需要明确的启动归属,避免跳过原生引导后又出现 Web 弹窗。
|
||||
|
||||
## 决策
|
||||
|
||||
[Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。独立打包的 React 渲染器提供入口、账号登录状态和 API Key 表单。它与 Web UI 共用 `StateDot` 加载组件,不启动 Web 插件图。Desktop 构建将本地 JavaScript 和 CSS 输出到 `lib/welcome`,并纳入应用安装包;文档继续使用禁止网络访问的内容安全策略。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。
|
||||
|
||||
冷启动在既无已存账号凭证、也无已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。启动分别读取账号凭证是否存在和 API Key 配置状态。账号登录后进入工作区;退登后仅在没有已配置的模型 API Key 时重新打开欢迎窗口。
|
||||
|
||||
生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。
|
||||
|
||||
Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在挂载前等待隔离 preload 返回该偏好和同一系统语言顺序,之后将解析后的语言变更通知桌面壳。只有设置中的明确选择会写入偏好;自动检测保持临时状态。桌面壳自带英文和简体中文词典,因此仅在 Client 中提供的语言包会在原生窗口回退到受支持的系统语言。
|
||||
|
||||
欢迎流程与启动恢复共用 Desktop 后端控制器。Host 启动期间,主窗口在屏幕外加载恢复文档;失败时显示该文档,成功后读取凭证和语言,再选择欢迎窗口或工作区。恢复操作保留控制器的串行重试与等待子进程退出的行为。应用 preload 只向壳文档提供恢复控件,只向应用文档提供语言桥接。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。
|
||||
|
||||
**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。
|
||||
|
||||
**同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。
|
||||
|
||||
**绘制替代红绿灯。** 原生控件保留平台窗口行为和无障碍能力。它们的尺寸与窗口外部圆角遵循操作系统,不严格复制 Figma 几何参数。
|
||||
|
||||
**独立的预览回调。** 纯视觉预览中的跳过无法证明能进入工作区,还会掩盖启动依赖缺失。预览使用与产品相同的回调和 Host。
|
||||
|
||||
## 后果
|
||||
|
||||
原生模糊强度和字体回退因系统而异。macOS 使用 menu vibrancy,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。模糊强度由原生材质控制。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登保留独立配置的 API Key。引导展示不会生成 Session 事件。
|
||||
+2
-2
@@ -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/architecture/2026-09-08-document-preview-operations.md
|
||||
2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2
|
||||
2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a
|
||||
2026-09-08-document-preview-operations.md: 220db6db0474f244f902cdeb89b297856243672b
|
||||
2026-09-08-document-preview-operations.zh.md: 7b838711e5118b8fcc072e4efb753abfc9756b56
|
||||
@@ -16,19 +16,19 @@ Document Preview separates resource observation from content reads. The [resourc
|
||||
|
||||
Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
|
||||
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls `ctx.remote.workspaceFiles.read` for text and `ctx.remote.workspaceFiles.readBytes` for native bytes, with `baseFile` for related resources. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
|
||||
|
||||
Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
|
||||
Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport uses the [binary workspace file transfer](2026-09-17-workspace-file-binary-transfer.md). Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
|
||||
|
||||
PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
|
||||
**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and delegates reads and store updates to its private injected face. The face reports the displayed source version or a failed load to the shared owner. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot.
|
||||
|
||||
**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads.
|
||||
|
||||
**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.
|
||||
**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session addressing and byte transport out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.
|
||||
|
||||
**UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation.
|
||||
|
||||
|
||||
@@ -16,19 +16,19 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
|
||||
|
||||
可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
|
||||
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
|
||||
[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 通过 `ctx.remote.workspaceFiles.read` 读取文本,通过 `ctx.remote.workspaceFiles.readBytes` 读取原生字节,关联资源使用 `baseFile`。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
|
||||
|
||||
Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
|
||||
Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输使用[工作区文件二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
|
||||
|
||||
PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
|
||||
**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,并将读取与 store 更新交给其私有注入 face。face 向共享 owner 报告已展示的源版本或加载失败。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
|
||||
|
||||
**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
|
||||
|
||||
**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。
|
||||
**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需处理 Session 寻址和字节传输。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。
|
||||
|
||||
**把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-09-09-consumer-owned-startup-strictness.md
|
||||
2026-09-09-consumer-owned-startup-strictness.md: 2f16078c7051b4038c1d48120b500f0fcd465aef
|
||||
2026-09-09-consumer-owned-startup-strictness.zh.md: c0fe61e5f708f79f6a5b903f3157ee358c3086bc
|
||||
2026-09-09-consumer-owned-startup-strictness.md: bbe975e13b251e2e0d18c4e002efbf1d362548f3
|
||||
2026-09-09-consumer-owned-startup-strictness.zh.md: 4bd2bfe026a13ccbd46215d2c0165b5a8c5327f7
|
||||
+1
-1
@@ -18,7 +18,7 @@ The audit treats a throwing `disabled` expression as an entry failure, not a dis
|
||||
|
||||
The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair.
|
||||
|
||||
This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-08-03-per-session-agent-presets.md) own a separate strict subtree audit.
|
||||
This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
|
||||
|
||||
该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。
|
||||
|
||||
该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-08-03-per-session-agent-presets.zh.md)持有独立的严格子树审计。
|
||||
该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+2
-2
@@ -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/architecture/2026-09-09-profile-resolution-generations.md
|
||||
2026-09-09-profile-resolution-generations.md: ced79eefc8c46a43cabd5b6a79db2644526c022f
|
||||
2026-09-09-profile-resolution-generations.zh.md: 98c509488aaeaa8d1b5e722bf6e3ea6346d45c46
|
||||
2026-09-09-profile-resolution-generations.md: 649c6c358fece5557031b4a21f3d3ebdd9dac8f7
|
||||
2026-09-09-profile-resolution-generations.zh.md: 4e84cc1f1b763c88b4ae96c4c44ae79de0d4c0d6
|
||||
+19
-12
@@ -8,11 +8,11 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
|
||||
|
||||
A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
|
||||
|
||||
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry.
|
||||
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves existing package mappings and local package names, permits linked-root membership changes, and never mutates a live table entry by entry.
|
||||
|
||||
## Decision
|
||||
|
||||
Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement.
|
||||
Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete successor with one reference replacement.
|
||||
|
||||
### One selection algorithm
|
||||
|
||||
@@ -62,19 +62,26 @@ The [source launcher](2026-07-29-dsh-source-launch-tsx-esm.md) uses tsx's ESM-on
|
||||
|
||||
For example, `@deepseek-ai/dsh-tools` creates its scheduler key with `Symbol()`. A Tools instance loaded from `lib/` cannot expose that scheduler through a key imported from the separate `src/` module instance. Source launches keep Tools and AgentLoop in `src/`; plain Node launches keep them in `lib/`. The scheduler retains its local Symbol; correct imports share one module instance.
|
||||
|
||||
The source launcher does not install a CommonJS TypeScript hook. `createRequire().resolve()` still selects the package's published JavaScript entry and requires that file to exist. Source-mode resolution tests therefore use fixture-provided CommonJS files; checks of real installation CommonJS entries run with build outputs present.
|
||||
|
||||
<a id="immutable-generations"></a>
|
||||
### Immutable generations
|
||||
|
||||
A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
|
||||
A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required dependency-graph manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured.
|
||||
|
||||
Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry.
|
||||
Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. Profile-importer hits and successful native selections can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Linked importer routes are not cached: each resolution reads current peer declarations at the ancestor positions it visits. Explicit CommonJS paths bypass interception entirely; non-default conditions never reuse a default-resolution cache entry.
|
||||
|
||||
The launcher constructs one startup generation. The service accepts an additive successor, but no package-manager transaction invokes replacement in this implementation.
|
||||
Linked-root membership is immutable within a generation. A successor may add or remove roots without restarting the process. When no remaining root covers a directory, subsequent resolutions there use native Node lookup, including new requests from already loaded modules. A request may then select a development copy or fail because the package is absent. Removing interception does not unload modules, replace existing references, or clear Node's own caches.
|
||||
|
||||
The router retains the real target of each successfully published link name for its lifetime, solely to validate successors. Removing a root does not erase that record or keep its directory intercepted. Re-adding the same name and target is allowed; a different target is rejected because Node caches real paths. Failed publication changes neither the current generation nor the recorded targets.
|
||||
|
||||
The launcher constructs one startup generation. The service accepts a complete successor, but no package-manager transaction invokes replacement in this implementation.
|
||||
|
||||
### Shared ESM and CommonJS rule
|
||||
|
||||
The resolver uses `node-addon-require-builtin` to read `internal/modules/esm/loader` and `internal/modules/cjs/loader`. The ESM adapter wraps the per-thread singleton `CascadedLoader` resolve methods. The CommonJS adapter wraps the internal builtin's `Module._resolveFilename`; that `Module` is the same object exported by `node:module`.
|
||||
|
||||
Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, interception, and native-after-interception package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses an interception hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order.
|
||||
Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profiles and registered linked roots, and every CommonJS request with explicit `paths`. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same package order with the request's conditions, while Node retains exact target resolution. Package self-references keep the original parent, including npm aliases. Profiles have a fixed interception position; linked importers apply current peer eligibility at each native ancestor position. Native CommonJS probes determine whether a legacy subpath miss continues to another position. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) owns the complete selection rule.
|
||||
|
||||
The adapters call the captured native resolver after routing. Node remains responsible for exports, import and require conditions, main files, subpaths, extensions, native caches, and error codes. Routed ESM failures replace the internal lookup anchor in Node's diagnostic with the original importer. A selected package's invalid export or missing target does not trigger another same-name candidate. CommonJS does not replace `_findPath` or reproduce `_resolveFilename`.
|
||||
|
||||
@@ -84,7 +91,7 @@ The guarantee covers Node's default `import`, `import()`, `import.meta.resolve`,
|
||||
|
||||
The runtime resolution lists the packages it supplies; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed runtime resolution is authoritative, including a miss; a service created without one retains native lookup for low-level embedders.
|
||||
|
||||
The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. A repeated query uses the same generation and therefore cannot drift from the route used for the import. Non-Node importers that need package metadata must explicitly implement the same deterministic resolver interface.
|
||||
The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. Package-directory queries use the same package selection, including current ancestor peer declarations for linked importers, but do not validate the requested subpath or load its file. Non-Node importers that need package metadata must explicitly implement the same resolver interface.
|
||||
|
||||
The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread interception and the Worker resolution lifetime; `resolver.ts` implements runtime resolution lookup and the Node Internal adapters; `worker-bootstrap.ts` installs the inherited runtime resolution in one thread. Profile selection and runtime resolution construction remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export.
|
||||
|
||||
@@ -100,17 +107,17 @@ New Workers inherit the latest published generation. Existing Workers keep the g
|
||||
|
||||
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of an existing package. The caller publishes an additive successor before mounting the new Loader row; this implementation does not provide that package transaction. A mount failure may leave the package installed but inactive.
|
||||
|
||||
Replacing, upgrading, or removing an already loaded package requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Generation replacement does not claim to unload modules.
|
||||
Changing or removing an existing runtime package mapping, or removing a recorded profile-local package name, requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
|
||||
### Filesystem and runtime carriers
|
||||
|
||||
The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver.
|
||||
The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor. Construction records links to directories outside the shared profiles tree and the active profile itself, including targets without their own manifest. Installation-scope package directories stay outside linked interception even when a broader linked root contains them. Eligible linked importers retain native ancestor order and per-position peer mappings. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records precedence and scope. Writable profile state and package-manager transactions remain outside the resolver.
|
||||
|
||||
Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime resolution mechanism as ordinary Node launches.
|
||||
|
||||
### Performance and verification
|
||||
|
||||
Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. Ordinary hot paths consist of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and one native resolution; a cache hit returns the generation-owned result directly. A CommonJS request may perform one native probe followed by one routed resolution when a local package directory exists without `exports` but lacks the requested subpath. Out-of-scope calls do not read manifests and cache only whether each parent belongs to the profile scope.
|
||||
Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. Profile hot paths consist of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and one native resolution; a cache hit returns the generation-owned result directly. Linked requests for supplied package names read the visited ancestor manifests and do not cache the selected route. CommonJS can probe multiple positions when legacy subpaths are missing. Out-of-scope calls do not read manifests and cache only whether each parent has an interception layer.
|
||||
|
||||
One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost.
|
||||
|
||||
@@ -135,7 +142,7 @@ Behavior tests exercise root order, transitive and peer dependencies, local and
|
||||
## Verification
|
||||
|
||||
- One eager computation supplies the runtime resolution; startup neither writes nor retires module-resolution data.
|
||||
- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors.
|
||||
- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors. They also cover linked-root removal, same-target restoration, overlapping roots, native misses, new requests from loaded modules, and relink rejection after removal.
|
||||
- [Source-launch tests](../../../../apps/cli/tests/source-launch.compat.spec.ts) and [built-bin tests](../../../../apps/cli/tests/built-bin.e2e.ts) run both profile layouts through the real CLI. They assert ESM/CJS versions, loaded paths, per-format dependency identity, and consistent Tools/AgentLoop module instances with an accessible scheduler key.
|
||||
- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
|
||||
- ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
|
||||
@@ -145,4 +152,4 @@ Behavior tests exercise root order, transitive and peer dependencies, local and
|
||||
|
||||
## Consequences
|
||||
|
||||
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.
|
||||
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Package mappings and local package names remain additive; linked-root membership may change without unloading modules.
|
||||
+19
-12
@@ -8,11 +8,11 @@ Status: implemented
|
||||
|
||||
profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。
|
||||
|
||||
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。
|
||||
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保留既有包映射和本地包名,允许调整 linked root 集合,不会逐项修改正在使用的表。
|
||||
|
||||
## Decision
|
||||
|
||||
profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。
|
||||
profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的后继 generation。
|
||||
|
||||
### 唯一选包算法
|
||||
|
||||
@@ -62,19 +62,26 @@ ESM `import` 和 CommonJS `require` 都选中这些版本。在每种模块格
|
||||
|
||||
例如,`@deepseek-ai/dsh-tools` 使用 `Symbol()` 创建 scheduler 键。从 `lib/` 加载的 Tools 实例,无法通过另一个 `src/` 模块实例导入的键暴露该 scheduler。源码启动让 Tools 与 AgentLoop 都位于 `src/`;普通 Node 启动让两者都位于 `lib/`。scheduler 保留模块本地的 Symbol;正确的 import 共享同一个模块实例。
|
||||
|
||||
源码启动器不安装 CommonJS TypeScript 钩子。`createRequire().resolve()` 仍选择包发布的 JavaScript 入口,并要求该文件存在。因此,源码模式的解析测试使用 fixture 提供的 CommonJS 文件,真实安装包的 CommonJS 入口检查则在构建产物存在时运行。
|
||||
|
||||
<a id="immutable-generations"></a>
|
||||
### 不可变 generation
|
||||
|
||||
一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
|
||||
一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取依赖图所需的全部 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。
|
||||
|
||||
选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。
|
||||
选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。profile importer 的命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。linked importer 的路由不缓存,每次解析读取所访问祖先位置当前的 peer 声明。显式 CommonJS paths 完全绕过拦截,非默认 conditions 不复用默认解析缓存。
|
||||
|
||||
launcher 只构造启动 generation。服务接受新增型后继 generation,但本实现没有包管理器事务调用替换操作。
|
||||
linked root 集合在一个 generation 内不可变。后继 generation 可以增加或移除 root,无需重启进程。目录不再被任何剩余 root 覆盖时,该目录后续的解析使用原生 Node 查询,包括已加载模块发起的新请求。请求可能因此选中开发副本,也可能因缺包而失败。移除拦截不会卸载模块、替换已有引用或清空 Node 自身的缓存。
|
||||
|
||||
解析器在自身生命周期内保留每个已成功发布的 link 名称对应的真实目标,仅用于校验后继 generation。移除 root 不会抹掉该记录,也不会让其目录继续被拦截。同名、同目标可以重新加入;不同目标会被拒绝,因为 Node 缓存真实路径。发布失败时,当前 generation 和已记录目标均不变。
|
||||
|
||||
launcher 只构造启动 generation。服务接受完整的后继 generation,但本实现没有包管理器事务调用替换操作。
|
||||
|
||||
### ESM 与 CommonJS 共用规则
|
||||
|
||||
resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader` 和 `internal/modules/cjs/loader`。ESM 适配器包装每线程单例 `CascadedLoader` 的 resolve 方法。CommonJS 适配器包装内部 builtin 导出的 `Module._resolveFilename`;该 `Module` 与 `node:module` 导出的对象相同。
|
||||
|
||||
两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、interception 和 native-after-interception 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 interception 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。
|
||||
两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 与已记录 linked root 之外的 parent,以及所有带显式 `paths` 的 CommonJS 请求,都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按请求的 conditions 遵循相同选包顺序,精确 target 解析仍由 Node 负责。包自引用保留原 parent,包括 npm alias。profile 使用固定拦截位置,linked importer 则在每个原生祖先位置应用当前 peer 条件。原生 CommonJS 探测决定旧式子路径缺失是否继续查询下一个位置。完整选包规则由[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)统一记录。
|
||||
|
||||
适配器完成路由后调用捕获的原生 resolver。exports、import/require conditions、main、subpath、扩展名、原生缓存和错误码仍归 Node 处理。路由后的 ESM 失败会把 Node 诊断中的内部查找锚点替换为原始 importer。选中包的无效 export 或缺失目标不会触发另一个同名候选。CommonJS 不替换 `_findPath`,也不复制 `_resolveFilename`。
|
||||
|
||||
@@ -84,7 +91,7 @@ resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader
|
||||
|
||||
runtime resolution 列出它提供的包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 runtime resolution 后,即使查询未命中也以它为准;底层嵌入方只安装服务而不提供 runtime resolution 时,服务保留 Node 原生查找。
|
||||
|
||||
解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。重复查询读取同一个 generation,因此不会偏离 import 使用的路线。需要包元数据的非 Node importer 必须显式实现同一个确定性 resolver 接口,不能把调用来源推断重新引入 Node 主路径。
|
||||
解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。包目录查询使用相同选包规则,包括 linked importer 祖先当前的 peer 声明,但不校验所请求的子路径或加载文件。需要包元数据的非 Node importer 必须显式实现同一个 resolver 接口。
|
||||
|
||||
实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程拦截与 Worker runtime resolution 的生命周期;`resolver.ts` 实现 runtime resolution 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 runtime resolution。profile 选包和 runtime resolution 构造留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。
|
||||
|
||||
@@ -100,17 +107,17 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列
|
||||
|
||||
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何既有 package name 的目录或版本。调用方先发布只增加映射的后继 generation,再挂载新的 Loader 配置项;本实现不提供该包事务。挂载失败可以留下已安装但未启用的包。
|
||||
|
||||
替换、升级或删除已加载包需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。generation 换代不声称卸载模块。
|
||||
修改或删除既有运行时包映射,或删除已记录的 profile 本地包名,需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
|
||||
### 文件系统与运行时载体
|
||||
|
||||
解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
|
||||
解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先。构造时记录目标同时位于共享 profiles 树和当前 profile 自身之外的目录链接,包括没有自身 manifest 的目标。installation 作用域包目录不参与 linked 拦截,即使更宽的 linked root 包含它们。符合条件的 linked importer 保留原生祖先顺序与逐位置的 peer 映射。优先级和范围见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。
|
||||
|
||||
运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime resolution 机制。
|
||||
|
||||
### 性能与验证
|
||||
|
||||
generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。普通热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和一次原生解析;缓存命中直接返回 generation 级结果。当本地 CommonJS 包目录没有 `exports` 且仅缺少所请求 subpath 时,一次请求可能先执行一次原生探测,再执行一次路由解析。作用域外调用不读取 manifest,只缓存 parent 是否位于 profile scope。
|
||||
generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。profile 热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和一次原生解析;缓存命中直接返回 generation 级结果。linked importer 请求运行时表提供的包名时,读取所访问的祖先 manifest,不缓存选中的路由。旧式子路径缺失时,CommonJS 可以探测多个位置。作用域外调用不读取 manifest,只缓存 parent 是否具有拦截层。
|
||||
|
||||
实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。
|
||||
|
||||
@@ -135,7 +142,7 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
## Verification
|
||||
|
||||
- 一次 eager 计算供应 runtime resolution;启动既不写入也不退休模块解析数据。
|
||||
- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。
|
||||
- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。测试还覆盖 linked root 移除、同目标恢复、重叠 root、原生缺包、已加载模块的新请求,以及移除后重新链接不同目标时的拒绝。
|
||||
- [源码启动测试](../../../../apps/cli/tests/source-launch.compat.spec.ts)与[构建入口测试](../../../../apps/cli/tests/built-bin.e2e.ts)通过真实 CLI 运行两种 profile 布局,断言 ESM/CJS 版本、加载路径、各模块格式内的依赖身份,以及一致的 Tools/AgentLoop 模块实例和可访问的 scheduler 键。
|
||||
- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
|
||||
- ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
|
||||
@@ -145,4 +152,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
|
||||
## Consequences
|
||||
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。包映射和本地包名仍只允许新增;linked root 集合可以变化,而不卸载模块。
|
||||
+2
-2
@@ -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/architecture/2026-09-09-workspace-file-read-authority.md
|
||||
2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c
|
||||
2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7
|
||||
2026-09-09-workspace-file-read-authority.md: 140816e8c48a6628133cf9208d5b48545e2e5eaf
|
||||
2026-09-09-workspace-file-read-authority.zh.md: 75ffee32c40930c459018ab0fde42d5324371ba8
|
||||
@@ -10,11 +10,11 @@ Workspace Files serves both file content and workspace navigation. Applying work
|
||||
|
||||
## Decision
|
||||
|
||||
`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps.
|
||||
`read`, `readBytes`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps.
|
||||
|
||||
`list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate.
|
||||
|
||||
`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
|
||||
`readBytes` with `baseFile` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML.
|
||||
|
||||
The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox.
|
||||
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应
|
||||
|
||||
## Decision
|
||||
|
||||
`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。
|
||||
`read`、`readBytes` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。
|
||||
|
||||
`list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。
|
||||
|
||||
`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
|
||||
带 `baseFile` 的 `readBytes` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。
|
||||
|
||||
[Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。
|
||||
|
||||
|
||||
@@ -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/architecture/2026-09-11-node-office-kit.md
|
||||
2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9
|
||||
2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a
|
||||
2026-09-11-node-office-kit.md: 2c95b7e27e059f6aaee21970fb30fca9ba1bb11b
|
||||
2026-09-11-node-office-kit.zh.md: bec5d6bb59f987f629ebfd81bb753eee121566f3
|
||||
@@ -10,11 +10,11 @@ Binary Office and OOXML files require document layout before they can be preview
|
||||
|
||||
## Decision
|
||||
|
||||
The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime.
|
||||
The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares the conversion service and shared Document Preview entry with stable IDs. The service owns authorized conversion Remote methods, whose descriptor is mounted by `api/remotes`; Office UI shares the Document Preview Loader lifetime.
|
||||
|
||||
The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
|
||||
|
||||
The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
|
||||
The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. Client PDF previews use this path for Word and PowerPoint; [browser spreadsheet preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX, XLS, CSV, and TSV independently. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches.
|
||||
|
||||
A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes.
|
||||
|
||||
@@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for
|
||||
|
||||
The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
|
||||
|
||||
Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
|
||||
Office responses use Typert binary-result projection and Connection multipart framing. The Client receives one `ArrayBuffer`-backed `Uint8Array` without a base64 string or a separate decode buffer. The built browser scenario verifies the multipart attachment and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
|
||||
|
||||
The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。
|
||||
[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明转换服务与共享文档预览入口。服务负责带授权的转换 Remote 方法,其描述符由 `api/remotes` 挂载;Office UI 共享文档预览的 Loader 生命周期。
|
||||
|
||||
[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
|
||||
|
||||
服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
|
||||
服务和 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器表格预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 XLSX、XLS、CSV 和 TSV。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。
|
||||
|
||||
converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
|
||||
|
||||
@@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF
|
||||
|
||||
[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
|
||||
|
||||
普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
|
||||
Office 响应使用 Typert 二进制结果投影和 Connection multipart 封装。Client 直接收到一个由 `ArrayBuffer` 支撑的 `Uint8Array`,不会生成 base64 字符串或单独的解码缓冲区。构建后的浏览器场景会同时验证 multipart 附件和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
|
||||
|
||||
[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。
|
||||
|
||||
|
||||
@@ -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-09-14-deepseek-account-login.md
|
||||
2026-09-14-deepseek-account-login.md: 458e020c2000fd3db5ee9806e99d4b653710cb1e
|
||||
2026-09-14-deepseek-account-login.zh.md: 2fb59009302309a67e2c3ec99050685ba39b4212
|
||||
@@ -0,0 +1,69 @@
|
||||
# Agent Note: DeepSeek account browser login
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-14-deepseek-account-login.zh.md)
|
||||
|
||||
Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default.
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop login must preserve local cancellation across the browser, UI, and Host and report success only after credentials are saved.
|
||||
|
||||
## Decision
|
||||
|
||||
The account Service Definition separates UI and model consumers from the platform protocol implementation. The platform provider registers an AuthorizationFlow and persists an owner-scoped GrantRecord. Account presence is derived from storage. Login attempts stay in memory and expose safe snapshots through the account Remote controller.
|
||||
|
||||
Local cancellation is authoritative. The provider stops callbacks and ignores late exchange results; auth_cancel invalidates the remote application and unexchanged code using authorize_id and the original PKCE verifier. Its background request does not delay local cancellation, and failure never restores login. session.commit admits persistence synchronously before its first await and rejects an already-cancelled flow. Cancellation after admission waits for the commit result. The callback redirects to auth_exchange.biz_data.authorized_url only after authorization settlement confirms storage.
|
||||
|
||||
Authorization registers a temporary /oauth/callback route on the Host webServer. The authenticated initiating client supplies its browser-accessible loopback HTTP origin, including any local SSH forwarding port. Non-loopback reverse proxies are unsupported. Cleanup removes only the route and preserves shared connections. Exchange failure returns Web to its login UI or focuses Desktop through account state; retry is explicit. PKCE secrets never cross UI transports. Model and file request tokens resolve only for the configured inferenceOrigin, defaulting to `https://api.deepseek.com`; redirects are refused. An explicit alternate origin requires grants from the configured Platform issuer, so changing the endpoint alone never forwards account credentials. Development loopback grants cannot authenticate production requests. API-key references remain separately stored.
|
||||
|
||||
Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses.
|
||||
|
||||
The Desktop profile supplies the native platform for Host API headers. Account and update-policy clients share one mapping to avoid different client identities for the same installation. This identity does not depend on a login attempt, since stored grants also issue requests after restart. Embedded pages use the same platform identity and retain their other separately configured headers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks.
|
||||
|
||||
API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances and positive bonus_wallets in separate rows, so promotional credit is never presented as recharge funds.
|
||||
|
||||
The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs.
|
||||
|
||||
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
|
||||
|
||||
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
|
||||
|
||||
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
|
||||
|
||||
DSH grants authenticate Platform, inference and Files requests with x-dsh-auth-token, without a Bearer prefix. API-key authentication remains protocol-specific.
|
||||
|
||||
Profile and recharge-wallet queries use independent getProfile and getBalance operations. The client publishes each result as it arrives, so a slow or failed balance request cannot delay the sidebar username. Account changes invalidate both pending results.
|
||||
|
||||
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
|
||||
|
||||
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
|
||||
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
|
||||
|
||||
Exchange user data supplies the first profile read after credential commit, avoiding a second request before displaying the username. The Host retains only projected UI fields in the active attempt, matched to its token and consumed once; later reads use current. Missing or malformed user data does not discard successful authorization.
|
||||
|
||||
The embedded Platform document stays hidden during loading because native child views cover renderer overlays. Only the current document may become visible after loading; returning or signing out invalidates pending visibility changes. Native ownership also ends when the application document reloads or is replaced, its renderer terminates, or its window closes. React effect cleanup alone is insufficient because document teardown may never execute it. Same-document and subframe navigation preserve the view.
|
||||
|
||||
Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap.
|
||||
|
||||
The Host binds private Platform sessions to the account provider lifetime. Removal or watch termination clears Electron’s session; replacement subscribes to the new provider, and disposed reads cannot publish old credentials.
|
||||
|
||||
The client distinguishes plugin disposal from terminal account-stream failure. RemoteStream aborts its signal after either outcome, so the plugin owns a separate disposal flag to preserve failure feedback while suppressing reports after unload.
|
||||
|
||||
## Verification
|
||||
|
||||
Provider tests exercise real loopback callbacks, invalid state, delayed exchange cancellation, credential persistence, sign-out, and official-origin restrictions. Desktop tests cover the native action bridge and localized entry. Manual development integration uses the platform dev middleware Mock and the real Electron Host, including cancellation before browser approval. Production backend credentials and installer scheme registration require release-environment validation.
|
||||
|
||||
## Related
|
||||
|
||||
[Credential records and flows](2026-08-13-credential-records-and-authorization-flows.md) remains the generic credential authority. [Desktop wrapper](2026-09-10-desktop-web-wrapper.md) owns the transport composition.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Agent Note: DeepSeek 账号浏览器登录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-14-deepseek-account-login.md) | 中文
|
||||
|
||||
登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。
|
||||
|
||||
## 问题
|
||||
|
||||
桌面登录必须在浏览器、UI 和 Host 之间保留本地取消意图,并只在凭证保存后宣告成功。
|
||||
|
||||
## 决策
|
||||
|
||||
账号服务定义将 UI 和模型使用方与平台协议实现分开。平台提供者注册 AuthorizationFlow,并保存按所有者寻址的 GrantRecord。账号是否存在从存储派生。登录尝试保留在内存中,通过账号 Remote 控制器提供安全快照。
|
||||
|
||||
本地取消具有决定权。提供者停止回调并忽略迟到的兑换结果;auth_cancel 使用 authorize_id 和原始 PKCE verifier 作废远程申请及未兑换的 code。后台请求不延迟本地取消,失败也不会恢复登录。session.commit 在第一次 await 前同步准入持久化,并拒绝已取消的流程。准入后的取消等待提交结果。回调只有在授权结束确认存储后才跳转 auth_exchange.biz_data.authorized_url。
|
||||
|
||||
授权在 Host webServer 上注册临时 /oauth/callback 路由。已鉴权的发起客户端提供浏览器可访问的本机 HTTP 来源,包含 SSH 本地转发端口。不支持非本机域名反向代理。清理仅移除路由,保留共享连接。兑换失败返回 Web 登录界面,或通过账号状态聚焦 Desktop;重试由用户明确发起。PKCE 私密数据不经过 UI 传输。模型和文件请求的 token 仅为配置的 inferenceOrigin 解析,默认为 `https://api.deepseek.com`;请求拒绝重定向。显式配置其他来源时,要求授权由配置的 Platform 来源签发,因此仅修改请求地址不会转发账号凭证。本机开发授权记录不能认证生产请求。API Key 引用独立保存。
|
||||
|
||||
平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。
|
||||
|
||||
Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新策略客户端共用一份映射,避免同一安装实例发送不同的客户端标识。此标识不依赖登录尝试,因为重启后保存的授权仍会发起请求。内嵌页面使用同一平台标识,并保留独立配置的其他请求头。
|
||||
|
||||
## 替代方案
|
||||
|
||||
把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。
|
||||
|
||||
## 影响
|
||||
|
||||
退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。
|
||||
|
||||
API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 分行显示 normal_wallets 充值余额与正额 bonus_wallets 赠金余额,避免将赠金额度显示为充值资金。
|
||||
|
||||
账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。
|
||||
|
||||
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
|
||||
|
||||
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
|
||||
|
||||
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
|
||||
|
||||
DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请求,不加 Bearer 前缀。API Key 继续使用各协议的认证方式。
|
||||
|
||||
资料和充值钱包余额分别通过 getProfile 与 getBalance 查询。客户端在各自返回时立即更新,余额请求慢或失败不会延迟侧边栏用户名显示。账号变化使两种进行中的查询结果失效。
|
||||
|
||||
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
|
||||
|
||||
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
|
||||
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
|
||||
|
||||
exchange 的 user 数据在凭证提交后供首次资料读取使用,展示用户名无需再等待一次请求。Host 仅在当前尝试中保留筛选后的 UI 字段,与 token 匹配并读取一次后移除;后续读取使用 current。缺失或格式无效的 user 不会使已成功的授权失效。
|
||||
|
||||
内嵌 Platform 文档在加载期间保持隐藏,因为原生子视图会覆盖渲染层浮层。加载完成后仅当前文档可显示;返回或退登会使待执行的显示操作失效。应用文档刷新或替换、其渲染进程终止或窗口关闭时,原生视图的所有权也随之结束。仅依赖 React effect 清理不足以保证释放,因为文档销毁时可能不执行它。同文档导航和子框架导航保留视图。
|
||||
|
||||
独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。
|
||||
|
||||
Host 将私有 Platform 会话绑定到账号提供者的生命周期。提供者移除或订阅结束时清空 Electron 会话;替换后订阅新的提供者,已释放的读取不得发布旧凭据。
|
||||
|
||||
客户端区分插件卸载和账户状态流的终止错误。RemoteStream 在两种情况下都会中止其信号,因此插件独立记录卸载状态,保留错误反馈并抑制卸载后的报告。
|
||||
|
||||
## 验证
|
||||
|
||||
提供者测试覆盖真实本机回调、错误 state、延迟兑换取消、凭证持久化、退出和官方来源限制。桌面测试覆盖原生操作桥接与本地化入口。手动开发联调使用平台 dev middleware Mock 和真实 Electron Host,包括在浏览器批准前取消。生产后端凭证及安装器 scheme 注册仍需发布环境验证。
|
||||
|
||||
## 相关记录
|
||||
|
||||
[凭证记录与流程](2026-08-13-credential-records-and-authorization-flows.zh.md)仍是通用凭证依据。[桌面外壳](2026-09-10-desktop-web-wrapper.zh.md)负责传输组合。
|
||||
+2
-2
@@ -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/architecture/2026-09-15-desktop-native-fatal-recovery.md
|
||||
2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19
|
||||
2026-09-15-desktop-native-fatal-recovery.md: fc1564b02e2f7b707d2b6f6d8e0947b1a78f142d
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: ceda54b4141c5fe58e6b385c86e6c1e4958dd261
|
||||
@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
|
||||
|
||||
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
|
||||
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. A crash report file under the application logs directory retains the complete reported diagnostic, and the dialog names it on its own line ([fatal diagnostics and crash reports](2026-09-22-fatal-diagnostics-and-crash-reports.md)). The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice, report path, and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
|
||||
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
|
||||
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。应用日志目录下的崩溃报告文件保留完整的已报告诊断,对话框单独一行标明该文件([致命诊断与崩溃报告](2026-09-22-fatal-diagnostics-and-crash-reports.zh.md))。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示、报告路径和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
|
||||
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
|
||||
|
||||
|
||||
@@ -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-09-16-experimental-voice-input.md
|
||||
2026-09-16-experimental-voice-input.md: 30c81ab233f27242dcf1fe2ef89bbb86542f9c04
|
||||
2026-09-16-experimental-voice-input.zh.md: 76aa8d6fc4bea32f194b20d05db4bf49af0be313
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Experimental voice input with explicit recognition providers
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-experimental-voice-input.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Dictation must preserve user control over when a coding task starts. Local recognition brings model files, a native runtime and CPU work; merely installing or enabling a UI contribution must not allocate these resources. Later cloud providers need a selection mechanism that does not silently change where recordings are processed.
|
||||
|
||||
## Decision
|
||||
|
||||
Five public packages under `packages/experimental/` compose one optional, default-disabled voice input bundle. Service Definition, SenseVoice Provider and Remote Consumer are separate because provider deployment and browser transport evolve independently. The browser UI dynamically mounts its generated Remote contribution. Stable API Remotes do not acquire an experimental dependency.
|
||||
|
||||
Provider ids select exact registrations. Resolution captures an instance, and deregistration closes admission, cancels and joins accepted work. There is no provider fallback: a local failure cannot authorize uploading speech to a cloud service. A new cloud provider supplies its own credentials and explicit processing location through the same registry.
|
||||
|
||||
Recordings and transcripts remain transient until the user submits ordinary text. Recognition does not start an agent turn or create an audio Session event. The input facade accepts asynchronous plain text against a captured draft revision, preserving reference chips and undo history. Changed drafts retain the recognized text for explicit insertion instead of replacing later edits. Session changes and plugin disposal invalidate late results and release recording resources. Capture failures notify the current recording activity immediately, independently of resource-close latency. Cancellation, failure and plugin withdrawal join one release promise, so ownership lasts until the AudioContext closes.
|
||||
|
||||
SenseVoice ONNX and Silero VAD run in one managed CPU subprocess. The native sherpa-onnx package includes ONNX Runtime; explicit preparation only downloads and verifies models; subsequent calls reuse the warm model until the configured idle deadline. Every activation verifies cached assets and restores complete caches to standby without starting the worker. Readiness therefore follows disk contents rather than the lifetime of a Provider instance. Recordings wait in the bounded queue during wake-up. The worker checks readiness at execution, so queued work cannot implicitly download resources invalidated by an earlier cache inspection. Inspection uses the same owned task as preparation, so cancellation and disposal join it. Disabling the provider joins its work and process exit while retaining reusable disk assets. Native browser recording and resampling avoid an additional audio conversion executable. The authenticated private loopback protocol carries complete PCM recordings, allowing cancellation to terminate the worker without sharing a long-running inference with another request.
|
||||
|
||||
The Host owns preparation independently of the view that started it. An ordered step list survives view remounts and records completion only as execution advances. Recognition weights, VAD and final verification have separate operations; downloads use pinned byte counts, while other work reports elapsed time. Preparation status and progress stay in bundle details through `plugins.bundle.config`, keyed by package name. The generic `plugins.bundle.activation` slot carries explicit enablement intent and detail navigation; the voice occupant waits for Host cache inspection and offers installation guidance only for unprepared local models. Enabling an already prepared provider does not prompt or download. A single composer activity Slot expands the voice controls while preserving the draft editor and submit action. The context meter sits below the composer; recording and transcription hide the meter along with ordinary toolbar controls until the activity closes. Shared primitives provide buttons, disclosure and status markers; only measured waveform drawing belongs to the voice UI. Recordings start on click and transcribe on Stop, with feedback inside the activity. Persistent preferences remain in plugin settings.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Always loading the model on plugin activation would make a UI toggle incur substantial startup time and memory even without dictation. Python/FunASR/PyTorch adds interpreter provisioning, dependency installation and disk cost; completed ONNX artifacts and platform-native packages serve CPU dictation. End users never export models. Browser-native speech recognition does not provide a uniform local-processing guarantee. Streaming voice interaction would require partial-result revision, turn arbitration and bidirectional audio semantics that complete-recording dictation does not need.
|
||||
|
||||
## Consequences
|
||||
|
||||
First use needs download time and disk space. Local means the Host machine, including a remote Host. A cancelled active inference loses its warm worker and the next request reloads it. Optional preparation paths support existing offline environments without silently changing defaults.
|
||||
|
||||
Verification covers exact-provider routing, registration teardown, bounded audio parsing, microphone release, stale-draft and Session-switch handling, worker queue/deadline recovery, and verified download publication. Real local inference uses the same managed worker as the product. Browser composition verifies optional enablement and draft insertion; ordinary Session replay verifies that only the submitted text reaches the model.
|
||||
|
||||
Real Node 24 and Electron 44 children complete FP32 and INT8 inference. Electron copies VAD output to avoid external-buffer restrictions. Three runs of the 4.2-second public Chinese sample agree; this short sample is not a complete accuracy evaluation. Desktop microphone requests are restricted to audio from the primary application window, with macOS authorization handled by the operating system. Windows, Linux and signed installers still require platform acceptance.
|
||||
|
||||
INT8 is the default to reduce download and storage cost; `precision: fp32` selects the reference weights. Public English and concatenated bilingual samples show word differences between precisions, so quantization is not treated as accuracy-equivalent. A roughly 101-second repeated-speech sample completes in both runtimes; its observed post-request RSS is not a peak-memory bound.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note:使用显式识别 Provider 的实验性语音输入
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-experimental-voice-input.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
听写必须保留用户对编码任务启动时机的控制。本地识别带来模型文件、原生运行时和 CPU 工作;仅安装或启用 UI 贡献不应分配这些资源。之后的云端 Provider 需要明确的选择机制,不能悄悄改变录音的处理位置。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/experimental/` 下的五个公开包组合成一个默认禁用的可选语音输入 Bundle。服务定义、SenseVoice Provider 和 Remote 消费者分开,因为 Provider 部署与浏览器传输独立演化。浏览器 UI 动态挂载其生成的 Remote 贡献。稳定 API Remotes 不引入实验性依赖。
|
||||
|
||||
Provider id 选择确切的注册。解析捕获实例,注销先拒绝新任务,再取消并等待已接收的任务。不存在 Provider 回退:本地失败不能授权将语音上传至云端服务。新增云端 Provider 通过同一注册表提供自己的凭据配置与显式处理位置。
|
||||
|
||||
录音与转写在用户提交普通文字前都是临时数据。识别不启动智能体轮次,也不创建音频 Session 事件。输入门面按照捕获的草稿版本接收异步纯文本,保留引用芯片与撤销历史。草稿变化时保留识别文字,等待显式插入,而非替换之后的编辑。切换 Session 和释放插件会使迟到结果失效并释放录音资源。采集失败立即通知当前录音活动,不受资源关闭耗时影响。取消、失败和插件撤销共同等待同一个释放 Promise,所有权持续到 AudioContext 关闭。
|
||||
|
||||
SenseVoice ONNX 与 Silero VAD 在一个托管 CPU 子进程中运行。原生 sherpa-onnx 包包含 ONNX Runtime;显式准备只下载并校验模型;后续调用复用预热模型,直到配置的空闲截止时间。每次启用都会校验缓存资源,完整缓存恢复为待唤醒状态,不启动工作进程。就绪状态因此取决于磁盘内容,而非 Provider 实例的存活时间。录音在唤醒期间进入有界队列等待。工作进程在执行时检查就绪状态,因此较早的缓存检查使资源失效后,排队任务也不能隐式下载资源。检查复用准备任务的生命周期,取消与释放都会等待检查结束。禁用 Provider 等待其任务和进程退出,同时保留可复用的磁盘资源。浏览器原生录音与重采样避免增加音频转换可执行文件。带认证的私有环回协议传输完整 PCM 录音,取消时可以终止工作进程,而不与另一请求共享长时间推理。
|
||||
|
||||
Host 独立于发起视图拥有准备任务。有序步骤列表在视图重新挂载后保留,仅随着执行推进记录完成。识别模型、VAD 与最终校验分别准备;下载使用固定字节数,其他工作报告等待时间。准备状态和进度通过以包名为 key 的 `plugins.bundle.config` 显示在详情页。通用 `plugins.bundle.activation` Slot 传递显式启用意图和详情导航;语音贡献等待 Host 缓存检查,只为尚未准备的本地模型提供安装引导。启用已准备的 Provider 不弹窗也不下载。单个输入框活动 Slot 展开语音控件,同时保留草稿编辑器和提交按钮。空闲时上下文用量按钮位于输入框下方;录音和转写期间,用量按钮与普通工具栏控件一起隐藏,关闭活动后恢复。按钮、折叠与状态标记复用共享原语,语音 UI 只负责实测波形绘制。点击开始录音,停止后转写,反馈留在活动栏内。持久化偏好仍在插件设置中。
|
||||
|
||||
## 考虑的替代方案
|
||||
|
||||
在插件启用时加载模型,会让一个 UI 开关在尚未听写时就产生大量启动时间和内存占用。Python/FunASR/PyTorch 增加解释器、依赖安装与磁盘成本;ONNX 成品和平台原生包满足 CPU 听写。用户端不执行模型导出。浏览器原生语音识别不能提供一致的本地处理保证。流式语音交互需要部分结果修订、轮次仲裁与双向音频语义,而完整录音听写无需这些机制。
|
||||
|
||||
## 影响与验证
|
||||
|
||||
首次使用需要下载时间和磁盘空间。本地指 Host 所在机器,包括远程 Host。取消运行中的推理会丢失预热进程,下次请求重新加载。可选准备路径支持已有离线环境,不会悄悄改变默认行为。
|
||||
|
||||
验证覆盖精确 Provider 路由、注册释放、有界音频解析、麦克风释放、过期草稿和 Session 切换处理、工作进程队列与截止时间恢复,以及经过校验的下载发布。真实本地推理使用与产品相同的托管工作进程。浏览器组合验证可选启用与草稿插入;普通 Session 回放验证只有提交的文字进入模型。
|
||||
|
||||
Node 24 与 Electron 44 的真实子进程均完成 FP32 和 INT8 识别。Electron 的 VAD 输出使用复制缓冲区,避免 external buffer 限制。4.2 秒公开中文样本的三次识别结果一致;该短样本不代表完整准确率评测。桌面麦克风请求限制为主应用窗口的音频请求,macOS 权限由系统处理。Windows、Linux 真实机器及签名后的安装包仍需平台验收。
|
||||
|
||||
INT8 以降低下载和存储成本为默认值,`precision: fp32` 选择参考权重。公开英文与拼接的中英双语样本显示两种精度存在用词差异,因此不将量化视为准确率等效。约 101 秒的重复语音样本在两种运行时均完成识别;请求后的 RSS 观测不构成峰值内存上限。
|
||||
+2
-2
@@ -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/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.md: 700ada15385d0ff95e2f7f1cc58545a28677a507
|
||||
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 23935312d2b09a524a1571f81c18ad8b5ffafc3f
|
||||
+2
-2
@@ -18,14 +18,14 @@ The page passes `view: 'page'` for each form. Official plugin cards and details
|
||||
|
||||
**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence.
|
||||
|
||||
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
|
||||
**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired.
|
||||
|
||||
**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
|
||||
- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page.
|
||||
- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section, and the configuration pages live on the Plugins page.
|
||||
- Settings lists the inventory only; the Settings browser goldens cover that read-only section.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
+2
-2
@@ -18,14 +18,14 @@ Status: implemented
|
||||
|
||||
**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。
|
||||
|
||||
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
|
||||
**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。
|
||||
|
||||
**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
|
||||
|
||||
## 后果
|
||||
|
||||
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
|
||||
- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。
|
||||
- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,配置页则位于插件页。
|
||||
- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
+6
@@ -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-09-17-plugin-detail-page-extension-slots.md
|
||||
2026-09-17-plugin-detail-page-extension-slots.md: d4bbca0c4466fbb78a8c56cfc4e327100385368f
|
||||
2026-09-17-plugin-detail-page-extension-slots.zh.md: f07efc3d3cadded04a7551867680cc058d532943
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Plugin detail pages as an extension point
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-plugin-detail-page-extension-slots.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Plugins page's detail pages — a bundle's page, a row's page, an official plugin's page — carried only what the page drew itself and the object's own configuration ([plugin configuration on the Plugins page](2026-09-16-plugin-configuration-on-the-plugins-page.md)). A plugin with something to say about a bundle it does not own — a diagnostics plugin's health check, a market plugin's update badge, a documentation plugin's README section — had no place on that bundle's page, and importing the page to extend it is forbidden by the client bundle purity rule.
|
||||
|
||||
## Decision
|
||||
|
||||
**The page declares three list slots keyed by region, not by object.** `plugins.detail.actions` renders at the head of a page before the page's own switch and uninstall; `plugins.detail.badge` beside the title after the version, beta, and problem tags; `plugins.detail.section` under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Each entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`. An entry decides from the subject whether to render and returns null otherwise; the page orders entries by `order`.
|
||||
|
||||
**The subject is a projection, not the store.** `PluginPackageRef` and `PluginRowRef` carry the name, version, installed and enabled facts, and the row list — the facts a contribution decides on — and nothing of the page's own state, so the page's store stays private and the contract stays small.
|
||||
|
||||
**A row's page still exists only for a configured row.** A list entry cannot say in advance which rows it has something for, and a row page with nothing on it shows nothing worth opening, so the configure control keeps its rule: a row is opened when a `plugins.row.config` entry names it, and contributions for the row render on that page.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One slot per object and region (`plugins.bundle.section`, `plugins.row.section`, ...).** Rejected: nine slots for three regions, and a contribution that spans objects would register three times. The subject discriminator carries the same information in three slots.
|
||||
|
||||
**Passing the page's `PackageView` and `PackageRow` as the subject.** Rejected: those types carry the Host's read-only reasons, errors, entry ids, and fiber phases, which are the page's business; exposing them would make store internals a public contract.
|
||||
|
||||
**Every row gets a page so row contributions always have a home.** Deferred: a row without configuration has nothing a page would show beyond its module and state; the rule can widen when a contribution needs it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A contribution is a slot registration with `import type` of `ui-plugin-manager` for the declarations; the page never names a contributing plugin.
|
||||
- The e2e fixture bundle (`@fixture/live-client`) contributes to its own bundle and row pages through the three slots; its browser half registers them with the rest, and they leave with it.
|
||||
- The slot catalog and the slots subsystem hierarchy list the three slots under `main`.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` renders contributions on a bundle's page, a row's page, and an official plugin's page with the subject each is about, and pins the projection; `browser-plugin.client.spec.tsx` pins the declarations. `apps/web/tests/plugin-config.e2e.ts` switches the fixture bundle on and asserts its action, badge, and section on the bundle's page and the row's page, their absence on an official plugin's page, and holds the bundle page's golden.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note:插件详情页作为扩展点
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-09-17-plugin-detail-page-extension-slots.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
插件页的详情页——组合包页、行页、官方插件页——只承载页面自绘的内容和对象自己的配置([插件配置放在插件页](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md))。对不属于自己的组合包有话要说的插件——诊断插件的健康检查、市场插件的更新标签、文档插件的 README 区块——在那个组合包的页面上无处安放,而通过 import 页面来扩展它被客户端包纯净性规则禁止。
|
||||
|
||||
## 决策
|
||||
|
||||
**页面按区域而不是按对象声明三个 list slot。** `plugins.detail.actions` 渲染在页头,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下——组合包页在组件列表之后,行页与官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`。条目根据 subject 决定是否渲染,否则返回 null;页面按 `order` 排列条目。
|
||||
|
||||
**subject 是投影,不是 store。** `PluginPackageRef` 与 `PluginRowRef` 携带包名、版本、是否已安装、是否启用以及行列表——贡献者据以判断的事实——不带页面自己的任何状态,因此页面的 store 保持私有,契约保持精简。
|
||||
|
||||
**行页仍然只为已配置的行存在。** list 条目无法预先说明它对哪些行有话要说,而一个空空如也的行页没有值得打开的内容,所以配置控件保留原规则:有 `plugins.row.config` 条目点名这一行时才可打开,对该行的贡献渲染在那个页面上。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**按对象和区域各开一个 slot(`plugins.bundle.section`、`plugins.row.section` 等)。** 否决:三个区域要九个 slot,而跨对象的贡献要注册三次。subject 判别式在三个 slot 里承载同样的信息。
|
||||
|
||||
**把页面的 `PackageView` 与 `PackageRow` 直接作为 subject。** 否决:这些类型携带 Host 的只读原因、错误、条目 id 与 fiber 阶段,这些是页面自己的事;暴露它们会让 store 内部成为公共契约。
|
||||
|
||||
**每一行都有页面,让行级贡献总有落脚处。** 推迟:没有配置的行,页面除了模块名和状态之外无可展示;等有贡献需要时再放宽这条规则。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一个贡献就是一次 slot 注册,只需 `import type` `ui-plugin-manager` 的声明;页面从不点名任何贡献插件。
|
||||
- e2e 夹具组合包(`@fixture/live-client`)通过三个 slot 向它自己的组合包页和行页贡献内容;它的浏览器半侧与其余注册一起完成,也一起离开。
|
||||
- slot 目录与 slots 子系统层级树在 `main` 下列出这三个 slot。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` 在组合包页、行页和官方插件页上渲染贡献并核对各自的 subject,钉住投影;`browser-plugin.client.spec.tsx` 钉住声明。`apps/web/tests/plugin-config.e2e.ts` 打开夹具组合包,断言组合包页与行页上的操作、标签和区块、官方插件页上没有它们,并保留组合包页的 golden。
|
||||
+6
@@ -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-09-17-settings-pages-as-companion-packages.md
|
||||
2026-09-17-settings-pages-as-companion-packages.md: de2f2897938fcc158c49bed772f399b4d9328d19
|
||||
2026-09-17-settings-pages-as-companion-packages.zh.md: 27d00faa156f0f48c6dc3995930be9ca629a732d
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Agent Note: Settings pages as companion packages
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-settings-pages-as-companion-packages.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The four official settings pages — shell, agent loop, Subagent, web search — lived in one client package, `ui-settings-plugins`, beside the Built-in plugins section. Every new built-in page grew that package, and a community bundle that wanted a settings page had no worked example of a page living with the plugin that owns the namespace. Moving each page into the Host package that registers its namespace was considered and does not fit: the shell namespace is registered by the executor families under the sandbox row, the loop package would gain a browser build for one number field, and the Subagent namespaces are registered from a subpath row, which carries no browser half.
|
||||
|
||||
## Decision
|
||||
|
||||
**A community plugin's settings page lives in its own package's browser half.** It registers into `plugins.bundle.config` or `plugins.row.config`, owns its copy and styles, and reads and writes through `ctx.settingsScope`. Its i18n is the client locale service, as the shipped community bundles already do.
|
||||
|
||||
**A built-in namespace's page lives in a companion client package.** `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent`, and `ui-settings-web-search` are client-only packages with an empty Host `apply`, one bare-name row each in the web composition's roster, registering into `plugins.item` while the Host serves their namespace. Host packages stay free of browser code; the namespace they edit is spelled as a literal, never imported from the owner.
|
||||
|
||||
**The form machinery moves to `ui-primitives`.** `SettingsFormModel` with `settingsNumberField` and `settingsTextField`, `SettingsValueField` and `SettingsSecretField`, and the `SettingsForm` frame are the kit every page renders with; the frame takes its copy as `labels` and the model takes a structural scope, so the baseline package depends on neither a plugin nor its dictionary.
|
||||
|
||||
**`settingsScope.whileServed(namespaces, register)` is the registration rule.** A page exists exactly while the Host serves one of its namespaces: the service watches the shared describe mirror, runs the registration when a namespace appears, and disposes it when none is served, so four packages share one rule instead of four copies.
|
||||
|
||||
**`ui-settings-plugins` keeps the Built-in plugins section only:** the navigation entry and the tab row feature-owned tabs register into.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A schema-driven generic form.** Rejected: a generic renderer covers scalar fields and stops at the first dynamic control — the Subagent page's live model catalogue, its cross-field rule, its one-mutation save — and every exception would become a role the schema is not for. Every page is code; a community plugin that ships no page has none.
|
||||
|
||||
**Dual-face Host packages for the built-in pages.** Rejected for the reasons above; the companion keeps the option open for a package that can carry a half.
|
||||
|
||||
**Copy for the form frame in one shared dictionary.** Rejected: it would couple every page to another plugin's dictionary keys; each page's dictionary carries the few frame strings.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold.
|
||||
- The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template.
|
||||
- The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row.
|
||||
- The shell page's `plugins.item` id is `shell`, where the old package registered `bash`: `subject.id` on an official page is now a discriminator `plugins.detail.*` contributions key on, so the id names the capability the page edits rather than one executor family.
|
||||
|
||||
## Testing
|
||||
|
||||
Each companion's `tests/apply.client.spec.ts` registers its page while the Host serves the namespace, withdraws it when the Host stops, and collapses on teardown; its card and controller specs carry the page's behavior from the former package. `packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`, `packages/client/ui-primitives/tests/settings-form.client.spec.tsx`, and `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` pin the kit; `packages/client/ui-settings/tests/while-served.client.spec.ts` pins the registration rule. `apps/web/tests/plugin-config.e2e.ts` exercises the shell and Subagent pages over the real wire.
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Agent Note:设置页作为伴生包
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-09-17-settings-pages-as-companion-packages.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
四个官方设置页——终端、Agent 循环、Subagent、网页搜索——都放在一个客户端包 `ui-settings-plugins` 里,与「内置插件」分区挨着。每新增一个内置页面都让这个包再长一截,而想要设置页的社区组合包也找不到"页面与拥有命名空间的插件放在一起"的现成样板。曾考虑把每个页面搬进注册其命名空间的宿主包,但行不通:shell 命名空间由执行器家族在 sandbox 行之下注册,循环包为了一个数字字段要长出浏览器构建,Subagent 的命名空间从子路径行注册,而子路径行不携带浏览器半侧。
|
||||
|
||||
## 决策
|
||||
|
||||
**社区插件的设置页放在自己包的浏览器半侧。** 它注册进 `plugins.bundle.config` 或 `plugins.row.config`,自己拥有文案和样式,通过 `ctx.settingsScope` 读写。它的 i18n 走客户端 locale 服务,已发布的社区组合包本来就是这么做的。
|
||||
|
||||
**内置命名空间的页面放在伴生客户端包里。** `ui-settings-shell`、`ui-settings-agent-loop`、`ui-settings-subagent`、`ui-settings-web-search` 是纯客户端包,宿主 `apply` 为空,各在 Web 组合花名册里占一条裸名行,在 Host 服务其命名空间期间注册进 `plugins.item`。宿主包不含浏览器代码;它们编辑的命名空间以字面量拼写,从不从 owner 导入。
|
||||
|
||||
**表单机制搬进 `ui-primitives`。** `SettingsFormModel` 及 `settingsNumberField`、`settingsTextField`,`SettingsValueField` 与 `SettingsSecretField`,以及 `SettingsForm` 框架,是每个页面共同渲染用的套件;框架以 `labels` 接收文案,模型接受结构化的 scope,因此基线包既不依赖任何插件也不依赖其字典。
|
||||
|
||||
**`settingsScope.whileServed(namespaces, register)` 是注册规则。** 页面恰好在 Host 服务其某个命名空间期间存在:服务监视共享的 describe 镜像,命名空间出现时执行注册,一个都不再服务时注销,四个包共用一条规则而不是四份副本。
|
||||
|
||||
**`ui-settings-plugins` 只保留「内置插件」分区:** 导航项和供功能插件注册标签页的标签行。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**由 schema 驱动的通用表单。** 否决:通用渲染器只覆盖标量字段,碰到第一个动态控件就止步——Subagent 页的实时模型目录、跨字段规则、一次 mutate 提交——而每个例外都会变成一个 schema 本不该承担的 role。每个页面都是代码;没有发页面的社区插件就没有页面。
|
||||
|
||||
**内置页面做成双面宿主包。** 因上述原因否决;伴生包给能带半侧的包留了这个选项。
|
||||
|
||||
**表单框架的文案放在一本共享字典里。** 否决:那会让每个页面耦合到另一个插件的字典键;每个页面的字典自带那几条框架文案。
|
||||
|
||||
## 后果
|
||||
|
||||
- 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。
|
||||
- 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。
|
||||
- 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。
|
||||
- 终端页在 `plugins.item` 里的 id 是 `shell`,旧包注册的是 `bash`:官方页面上的 `subject.id` 现在是 `plugins.detail.*` 贡献可以据以判断的标识,所以 id 以页面编辑的能力命名,而不是某一个执行器家族。
|
||||
|
||||
## 测试
|
||||
|
||||
每个伴生包的 `tests/apply.client.spec.ts` 在 Host 服务命名空间时注册页面、停止服务时注销、teardown 时收拢;其 card 与 controller 用例延续原包里的页面行为。`packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`、`packages/client/ui-primitives/tests/settings-form.client.spec.tsx` 与 `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` 钉住套件;`packages/client/ui-settings/tests/while-served.client.spec.ts` 钉住注册规则。`apps/web/tests/plugin-config.e2e.ts` 走真实链路验证终端页与 Subagent 页。
|
||||
@@ -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-09-17-shared-office-runtime.md
|
||||
2026-09-17-shared-office-runtime.md: 25b6ddccbb5b17b93bec2c0b024dfcb63e94abc3
|
||||
2026-09-17-shared-office-runtime.zh.md: 99dffe8d0233e60480689ff983b6261cbb83bb7b
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Shared Office runtime for Desktop and SDK
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-shared-office-runtime.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions.
|
||||
|
||||
## Decision
|
||||
|
||||
[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The packaged SDK profile enables the query and Office skills from its carrier default; `DSH_PRIMARY_RUNTIME` overrides that path, including an empty-string opt-out. It reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly.
|
||||
|
||||
The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64/ARM64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection.
|
||||
|
||||
The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active.
|
||||
|
||||
`runtime.json` stores Python, Node.js and pnpm versions at the top level and all Python distribution versions in `pythonPackages`. Build metadata does not single out numpy or pandas. The shared parser accepts legacy `components` files without rewriting them, retains their consistency checks, and returns only the canonical flat fields. Mixed formats are rejected. The build digest includes the assembly format so changed manifest bytes invalidate payload reuse.
|
||||
|
||||
The Python runtime wheel carries target-native CPython and Office libraries plus external skills in a sibling `<platform>-<arch>/` resource directory. Short platform directory names leave room for Python extension and dependency DLL paths after installation on Windows. Bundling supplies defaults; the skill registry still lets project, custom and user skills override same-name bundled entries, and profile patches can disable or replace Office skills without replacing Python. This retains configurable workflows while eliminating a separate environment installation step. Real filesystem resources let Python load native extensions and skills invoke their shared checker without startup extraction. The wheel grows by the compressed payload size; public-index size limits remain enforced by the release workflow.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query.
|
||||
|
||||
**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles.
|
||||
|
||||
**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment.
|
||||
|
||||
**One component field per Python library.** This duplicates the distribution map and requires schema changes for each library. A complete map records the locked package set independently of the interpreter and package-manager fields.
|
||||
|
||||
## Consequences
|
||||
|
||||
Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned.
|
||||
|
||||
SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Desktop 与 SDK 共享 Office 运行时
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-shared-office-runtime.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。
|
||||
|
||||
## 决策
|
||||
|
||||
[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。打包的 SDK profile 通过载体默认路径启用查询和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖该路径,空字符串表示禁用。它原位读取 payload。公共 base 与 `sdk-minimal` 不会隐式启用 Office。
|
||||
|
||||
[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64/ARM64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。
|
||||
|
||||
[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。
|
||||
|
||||
`runtime.json` 在顶层保存 Python、Node.js 和 pnpm 版本,并在 `pythonPackages` 中保存全部 Python 分发包版本。构建元数据不单独列出 numpy 或 pandas。共享解析器接受旧 `components` 文件而不改写它们,保留旧格式的一致性校验,并仅返回统一的扁平字段。混合格式会被拒绝。构建摘要包含组装格式,因此清单字节变化会使旧产物身份失效。
|
||||
|
||||
Python runtime wheel 将目标平台的 CPython、Office 库和外部 skills 放在可执行文件同级的 `<platform>-<arch>/` 资源目录中。较短的平台目录名为 Windows 安装后的 Python 扩展与依赖 DLL 路径保留长度空间。打包提供默认资源;skill 注册表仍允许项目、自定义目录和用户 skills 覆盖同名随包条目,profile patch 可在保留 Python 的同时禁用或替换 Office skills。这样既保留工作流配置能力,又省去单独安装环境的步骤。真实文件系统资源允许 Python 加载原生扩展,并让 skills 调用共用检查脚本,无需启动时解包。wheel 体积增加一个压缩 payload;发布流程仍执行公开索引的大小限制检查。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。
|
||||
|
||||
**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。
|
||||
|
||||
**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。
|
||||
|
||||
**为每个 Python 库增加组件字段。** 这会重复分发包版本表,并使新增库要求修改 schema。完整版本表将锁定包集合与解释器、包管理器字段分开记录。
|
||||
|
||||
## 影响
|
||||
|
||||
同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。
|
||||
|
||||
SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。
|
||||
+6
@@ -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-09-17-workspace-file-binary-transfer.md
|
||||
2026-09-17-workspace-file-binary-transfer.md: b419c8bd15f51246825ceddf2905ec055bbc70ae
|
||||
2026-09-17-workspace-file-binary-transfer.zh.md: 9cf3d7074c3d536a5c8a39732231c6996d6f6ead
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Binary workspace file transfer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-workspace-file-binary-transfer.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Document previews need native bytes. Encoding file contents as base64 adds about one third to the payload before compression and requires browser decoding. A separate file Fetch route also duplicates the Gateway's method dispatch, Session lookup, and error handling.
|
||||
|
||||
## Decision
|
||||
|
||||
The [Remote method architecture](2026-08-02-typert-remote-method-calls.md) owns method dispatch and lifetime; this note defines binary success encoding and Client validation. The [workspace file service](2026-09-05-workspace-files-service.md) exposes one binary `readBytes` Remote. Its required options object independently selects a byte `range` and a `baseFile` for relative target resolution. Without `range`, it reads a complete file. This avoids separate method names for orthogonal choices while preserving bounded `fs.readBytes` and `fs.readByteRange` reads.
|
||||
|
||||
Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway executes the generated encoder, keeps strict JSON values unchanged, and performs runtime byte detection for source-mode calls. It returns JSON-compatible metadata and result-relative byte attachments to Connection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not inspect business values or depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, result projection, and mount lifetime; feature code needs no multipart reader or dedicated route.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Separate `readAll`, range, and related-file methods mix independent path and extent choices. One options object permits their combination without adding another method.
|
||||
- A workspace-only Fetch route keeps binary transport out of Remote but requires feature-specific dispatch, Session lookup, and error decoding.
|
||||
- A whole-result binary marker or a reserved `data` field couples transport to one business result. Type-directed codecs and attachment paths support independently named and nested fields without adding result-wide categories.
|
||||
- A custom metadata prefix needs owned framing and length validation. FormData delegates those mechanics to the platform, at the cost of Blob copies.
|
||||
- Metadata HTTP headers constrain long Unicode paths. Multipart fields keep paths in the body; HTML stays in an isolated Blob iframe rather than a navigable Host response.
|
||||
|
||||
## Consequences
|
||||
|
||||
Byte ranges retain only the requested bytes under `maxBytes`, independently of `maxFileBytes`. Complete reads retain a bounded whole file; this is not a streaming preview or zero-copy optimization. On HTTP disconnect, the bridge aborts the request signal and drains materialized response chunks without writing to the socket: cancelling Node multipart bodies can race their producer and cause an unhandled `ERR_INVALID_STATE` rejection. Office authorization probes use the same Remote with a one-byte range; converted PDFs remain base64 in the separate Office Remote and use its bounded decoder. Plugin callers migrate to `readBytes` options and stop decoding its data as base64. Binary arguments, events, and stream items remain unsupported; recursive types require acyclic runtime values. No Session event or persistence format changes.
|
||||
|
||||
## Validation
|
||||
|
||||
Workspace tests cover complete and ranged reads, combined base-file resolution, limits, Session lookup, cancellation, and binary RPC round trips. Connection and Gateway tests cover nested and multiple attachments, framing, correlation, malformed multipart, and cancelled or withdrawn calls; Typert tests cover recursive result codecs, Client declarations, and rejected binary inputs or streams. The built browser scenario checks raw PNG bytes plus HTML, PDF, image, and text previews. Local benchmarks compare full and ranged reads using identical synthetic files, and distinguish transport/decoding from the complete read path.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 工作区文件二进制传输
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-workspace-file-binary-transfer.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
文档预览需要原生字节。将文件内容编码为 base64 会在压缩前使载荷增加约三分之一,并要求浏览器解码。独立的文件 Fetch 路由还会重复 Gateway 的方法分发、Session 查找和错误处理。
|
||||
|
||||
## 决策
|
||||
|
||||
[Remote 方法架构](2026-08-02-typert-remote-method-calls.zh.md)负责方法分发与生命周期;本记录规定二进制成功响应的编码及 Client 校验。[工作区文件服务](2026-09-05-workspace-files-service.zh.md)提供单一的二进制 `readBytes` Remote。其必填选项对象独立选择字节 `range` 和用于相对目标解析的 `baseFile`。未传 `range` 时读取完整文件。这避免为正交的选择设置不同方法名,同时保留有界的 `fs.readBytes` 和 `fs.readByteRange` 读取。
|
||||
|
||||
Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 执行生成的 encoder,保持 strict JSON 值不变,并为源码模式调用执行运行时字节识别;随后向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id;它不检查业务值,也不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消、结果投影及挂载生命周期;功能代码无需 multipart 读取器或专用路由。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- 分立的 `readAll`、范围和关联文件方法混合了路径与读取范围两个独立选择。单一选项对象支持组合,无需再增加方法。
|
||||
- 工作区专用 Fetch 路由让二进制传输独立于 Remote,但需要功能专属的方法分发、Session 查找与错误解码。
|
||||
- 整体结果的二进制标记或保留的 `data` 字段会将传输与某一种业务结果耦合。按类型生成的 codec 和附件路径支持独立命名及嵌套字段,无需新增整体结果分类。
|
||||
- 自定义元数据前缀需要自有的帧格式与长度校验。FormData 将这些机制交给平台,代价是 Blob 复制。
|
||||
- 元数据 HTTP 头会限制较长的 Unicode 路径。Multipart 字段将路径保留在正文中;HTML 仍置于隔离的 Blob iframe,不作为可导航的 Host 响应。
|
||||
|
||||
## 影响
|
||||
|
||||
字节范围仅保留 `maxBytes` 内的请求字节,不受 `maxFileBytes` 约束。完整读取保留有上限的整个文件;这不是流式预览或零复制优化。HTTP 连接断开后,桥接器中止请求信号,并排空已准备的响应块,不再写入 socket:取消 Node multipart 响应体可能与其生产方竞争,导致未处理的 `ERR_INVALID_STATE` 拒绝。Office 授权探测使用同一 Remote 的单字节范围;转换后的 PDF 仍通过独立的 Office Remote 传输 base64,并使用其有界解码器。插件调用方迁移到 `readBytes` 选项,并停止将其 data 按 base64 解码。仍不支持二进制参数、事件和流条目;递归类型要求运行时值无环。不改变 Session 事件或持久化格式。
|
||||
|
||||
## 验证
|
||||
|
||||
Workspace 测试覆盖完整与分段读取、基准文件解析组合、上限、Session 查找、取消以及二进制 RPC 往返。Connection 和 Gateway 测试覆盖嵌套及多附件、帧格式、关联关系、畸形 multipart,以及取消或撤销的调用;Typert 测试覆盖递归结果 codec、Client 声明,以及对二进制输入和流的拒绝。构建后的浏览器场景验证原始 PNG 字节以及 HTML、PDF、图片和文本预览。本机 benchmark 使用相同合成文件比较完整与分段读取,并区分传输/解码和完整读取路径。
|
||||
Loaded 100 of 3460 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user