diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml similarity index 68% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 5d89e6b732..fc866f6f10 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md index 47f2e28c2a..a1f2ad294e 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md @@ -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) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md index 88b643ea71..b745c22c91 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -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) | 中文 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml similarity index 67% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml index 10665d6e64..e49130d5ee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md index 4695074429..da996e5b37 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md @@ -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) diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md index 3b416c53dc..15fd4eb252 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md @@ -1,6 +1,7 @@ # Agent Note: 可移动的强更窗口 Status: implemented +Archived: 2026-09-18 [English](2026-09-16-movable-mandatory-update-window.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -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 diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md new file mode 100644 index 0000000000..f2f955e36a --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -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. diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md new file mode 100644 index 0000000000..5a4c51a6cc --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -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` 拥有合并后的全部文案,包括时长词汇。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 88573f7db2..6fc82fe8a6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -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", diff --git a/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..0af0acb43a --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md index 13d0bfc7c4..07f8a6553c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md @@ -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) diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 2b93ac1b81..58757a9774 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -1,6 +1,7 @@ # Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 Status: implemented +Archived: 2026-09-20 [English](2026-08-08-copy-only-preset-authoring.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index e7aa78354c..22bf3c01c4 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index c386d3b338..5960eb2686 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -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 ` 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 ` 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 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 002514740a..ea63ae8296 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -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 ` 前缀与 `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 ` 前缀与 `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)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 072caec450..d2f8d60d2d 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index fa6697aad6..e32aec72f4 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -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 - - -(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. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index a995eafc78..2941fc5167 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -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 - - -(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。可共享的存储部分更小:保存文本,并返回定位符与检索提示。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index d12179dde4..e09f7db568 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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: 56c9770966647e97763e0eaf339e4589fba673fb -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7260d11c84f8fb07cf6c4691357f92e0d00fd8f3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 653fb3d9b782d9010f693e4af8ad94475abb9d94 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 56c9770966..7260d11c84 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched. +The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. + Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. ### The serving interface is a plugin inside the dsh application @@ -38,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. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 04351105a5..653fb3d9b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。 +同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 + 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 ### 对外服务接口是 dsh 应用中的插件 @@ -38,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,发现其工具,并完成一次由模型请求的调用。 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 16ecfcc400..a3dae54b9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 0963134922..d59accf2dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index baf039d495..8fb0728eb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -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 竞争下的完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 730cfb413c..608fe717d5 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index a8d016a707..b8c23108a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -46,7 +46,7 @@ A `require.async("./client..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///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/??/client.js,/client.js&rev=` points to `/plugins/??/client.js.map,/client.js.map&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 ` diff --git a/apps/desktop/renderer/welcome.css b/apps/desktop/renderer/welcome.css index 9dcc614f1b..3d5fc88460 100644 --- a/apps/desktop/renderer/welcome.css +++ b/apps/desktop/renderer/welcome.css @@ -63,7 +63,8 @@ body { padding: 6px 16px; background: var(--welcome-tint); color: var(--dsw-alias-label-primary); - font-family: var(--dsw-font-family); + font-family: var(--dsw-font-family-brand); + font-weight: 300; user-select: none; -webkit-font-smoothing: antialiased; } @@ -101,10 +102,10 @@ body { .tagline { margin: 0; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); font-size: 24px; font-weight: 300; - line-height: 44px; + line-height: 40px; text-align: center; white-space: nowrap; } @@ -115,17 +116,16 @@ body { font: inherit; } -.tagline p { - margin-top: 8px; -} - .tagline em { color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-family-brand); font-size: 26px; font-style: normal; - font-weight: 400; - letter-spacing: 0.02em; + font-weight: 500; +} + +:lang(en) .tagline em { + font-size: inherit; } .actions { @@ -148,7 +148,7 @@ body { padding: 8px 16px; border: 1px solid transparent; border-radius: 10px; - font-family: inherit; + font-family: var(--dsw-font-family); font-size: 14px; font-weight: 500; line-height: 22px; @@ -200,6 +200,8 @@ body { corner-shape: round; background: transparent; color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-family-brand); + font-weight: 300; font-size: 14px; line-height: 20px; } @@ -243,16 +245,16 @@ body { .key-heading h1 { margin: 0; font-size: 20px; - font-weight: 500; + font-weight: 300; line-height: 32px; letter-spacing: 0.01em; } .key-heading p { margin: 0; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); font-size: 16px; - font-weight: 400; + font-weight: 300; line-height: 22px; letter-spacing: 0.02em; } @@ -274,6 +276,7 @@ body { background: transparent; color: var(--dsw-alias-label-primary); font-family: inherit; + font-weight: inherit; font-size: 14px; line-height: 24px; user-select: text; @@ -322,8 +325,9 @@ body { border: 0; border-radius: 0; background: transparent; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); font-family: inherit; + font-weight: inherit; font-size: 14px; line-height: 20px; cursor: pointer; @@ -332,3 +336,5 @@ body { } #auth-loading:disabled { opacity: 1; } .actions .welcome-loading { color: inherit; } + +.auth-waiting .copy-link:disabled { cursor: default; } diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index b1661646e5..00ebf8c5c0 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,13 +7,14 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' -/** Public updater URL for one release target. */ +/** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string readonly keyPrefix: string + readonly binaryKeyPrefix: string } /** Public updater URL and private COS destination for one upload target. */ @@ -62,12 +63,12 @@ export function desktopUpdateMetadataFilename( ): string /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param env - Packaging or upload environment. * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved updater configuration. - * @throws When the test deployment lacks a valid HTTPS origin. + * @throws When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig( env: NodeJS.ProcessEnv, @@ -81,7 +82,7 @@ export function resolveDesktopAutoUpdateConfig( * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved upload configuration. - * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @throws When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig( env: NodeJS.ProcessEnv, diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index eebd20156a..464d079b4d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -120,12 +120,12 @@ function httpsOrigin(value, name) { } /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. - * @throws {Error} When the test deployment lacks a valid HTTPS origin. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const environment = resolveDesktopAutoUpdateEnvironment(env) @@ -137,12 +137,21 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) } - const keyPrefix = `dsh-desk/feeds/${target}` + let releasePrefix = 'dsh-desk' + if (environment === 'test') { + const releaseId = requiredEnvironmentValue(env, 'DOWNLOAD_TEST_RELEASE_ID') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) { + throw new Error('desktop auto-update: DOWNLOAD_TEST_RELEASE_ID must contain 32 lowercase hexadecimal characters') + } + releasePrefix += `/${releaseId}` + } + const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, target, origin, keyPrefix, + binaryKeyPrefix: `${releasePrefix}/bin/${target}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -152,8 +161,8 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. - * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { const update = resolveDesktopAutoUpdateConfig(env, platform, arch) diff --git a/apps/desktop/scripts/desktop-build-commit.d.mts b/apps/desktop/scripts/desktop-build-commit.d.mts new file mode 100644 index 0000000000..82118e24e1 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-commit.d.mts @@ -0,0 +1,32 @@ +/** Commit a build came from, and whether its checkout carried uncommitted changes. */ +export interface DesktopBuildCommit { + readonly commit: string + readonly dirty: boolean +} + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV: 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV: 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param repositoryRoot - Directory to inspect. + * @returns The commit being packaged and whether its tree was modified. + */ +export function readDesktopBuildCommit(repositoryRoot: string): DesktopBuildCommit + +/** + * Resolve the commit a parent packaging process recorded. + * @param env - Packaging environment. + * @returns The packaged commit, or undefined outside a packaging run. + */ +export function resolveDesktopBuildCommit(env: NodeJS.ProcessEnv): DesktopBuildCommit | undefined + +/** + * Describe the packaged commit as the environment variables child processes read. + * @param packaged - Commit and tree state to pass down. + * @returns Variables to merge into a child environment. + */ +export function desktopBuildCommitEnvironment(packaged: DesktopBuildCommit): Record diff --git a/apps/desktop/scripts/desktop-build-commit.mjs b/apps/desktop/scripts/desktop-build-commit.mjs new file mode 100644 index 0000000000..28d7cda513 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-commit.mjs @@ -0,0 +1,57 @@ +/** + * Carry the commit a build came from into its artifacts. + * + * A build that reaches a colleague or a test feed is not reachable from a tag, + * so the only way back to its sources is what the build recorded about itself. + * The packaging entry reads the checkout once and passes the result to every + * child process, which run against a build tree rather than a checkout. + */ + +import { execFileSync } from 'node:child_process' + +/** Environment variable that carries the packaged commit. */ +export const DESKTOP_BUILD_COMMIT_ENV = 'DSH_DESKTOP_BUILD_COMMIT' + +/** Environment variable that records whether the packaged checkout had uncommitted changes. */ +export const DESKTOP_BUILD_DIRTY_ENV = 'DSH_DESKTOP_BUILD_DIRTY' + +/** + * Read the checkout's current commit and whether it carries uncommitted changes. + * @param {string} repositoryRoot - Directory to inspect. + * @returns {{ commit: string, dirty: boolean }} The commit being packaged and whether its tree was modified. + */ +export function readDesktopBuildCommit(repositoryRoot) { + const git = (args) => execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' }).trim() + return { + commit: git(['rev-parse', 'HEAD']), + dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '', + } +} + +/** + * Resolve the commit a parent packaging process recorded. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ commit: string, dirty: boolean } | undefined} The packaged commit, or undefined outside a packaging run. + */ +export function resolveDesktopBuildCommit(env) { + const commit = env[DESKTOP_BUILD_COMMIT_ENV]?.trim() + if (commit === undefined || commit === '') return undefined + if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`desktop build commit: ${DESKTOP_BUILD_COMMIT_ENV} must be a commit hash`) + const dirty = env[DESKTOP_BUILD_DIRTY_ENV]?.trim() + if (dirty !== undefined && dirty !== '' && dirty !== '0' && dirty !== '1') { + throw new Error(`desktop build commit: ${DESKTOP_BUILD_DIRTY_ENV} must be 0 or 1`) + } + return { commit, dirty: dirty === '1' } +} + +/** + * Describe the packaged commit as the environment variables child processes read. + * @param {{ commit: string, dirty: boolean }} packaged - Commit and tree state to pass down. + * @returns {Record} Variables to merge into a child environment. + */ +export function desktopBuildCommitEnvironment(packaged) { + return { + [DESKTOP_BUILD_COMMIT_ENV]: packaged.commit, + [DESKTOP_BUILD_DIRTY_ENV]: packaged.dirty ? '1' : '0', + } +} diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index f20d60da48..b26c633d9c 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -4,6 +4,7 @@ import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment. export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string + readonly unsignedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 375e61b542..841da2c4ba 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -32,7 +32,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -43,6 +43,7 @@ export function desktopTargetBuildPaths(target) { return { root, artifacts: join(root, 'artifacts'), + unsignedArtifacts: join(root, 'unsigned-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts new file mode 100644 index 0000000000..32ed5a37b2 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -0,0 +1,156 @@ +/** + * Suggest the next build version for a product version, so the sequence number + * is derived rather than remembered. + * + * `apps/desktop/README.md` gives the form as `..` + * for a prerelease base and `-test..` for a + * stable one. What makes an index correct is which ones are taken, and that is + * recorded where the builds went: the update bucket for an uploaded build, the + * local output directory for one that was only handed over. Asking the bucket + * first is what keeps a test feed's numbering unique across machines, and a + * fresh CI checkout has no local artifacts to fall back on. + * + * Reusing an index would overwrite a published installer and invalidate the + * checksum in the feed that already points at it, so a bucket that answers + * must answer completely: the listing follows its pages, and a query that + * cannot finish within the deadline falls back to the local scan rather than + * numbering against a partial answer. + */ + +import { readdir } from 'node:fs/promises' +import { parse } from 'semver' +import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { DESKTOP_AUTO_UPDATE_ENV, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts' +import type { DesktopPackageTargetName } from './package-target.ts' + +/** How long the whole bucket listing may take before the suggestion falls back to local artifacts. */ +const LISTING_DEADLINE_MS = 8_000 + +/** Objects one listing page may return. */ +const LISTING_PAGE_SIZE = 1000 + +/** Artifact name electron-builder writes for one build, on either platform. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u + +/** Inputs that decide which versions are already taken. */ +export interface DesktopBuildVersionSuggestionOptions { + readonly productVersion: string + readonly target: DesktopPackageTargetName + readonly environment: NodeJS.ProcessEnv + /** Date segment to number within; defaults to today where the build runs. */ + readonly date?: string + /** Directory electron-builder writes installers into for this build. */ + readonly artifactsRoot: string +} + +/** + * Format a date as the convention's segment. + * @param date - Date to format. + * @returns The date as `YYYYMMDD` in the build host's own time zone. + */ +export function desktopBuildDateSegment(date: Date = new Date()): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + return `${String(date.getFullYear())}${month}${String(date.getDate()).padStart(2, '0')}` +} + +/** + * Read the sequence numbers already used for one product version and date. + * @param versions - Versions found in a bucket or directory. + * @param prefix - Everything a numbered build carries before its index. + * @returns Every index present, unordered. + */ +function sequenceNumbers(versions: Iterable, prefix: string): number[] { + const numbers: number[] = [] + for (const version of versions) { + if (!version.startsWith(prefix)) continue + const index = version.slice(prefix.length) + if (/^\d+$/u.test(index)) numbers.push(Number(index)) + } + return numbers +} + +/** + * Read the versions one output directory already holds. + * @param artifactsRoot - Directory electron-builder wrote installers into. + * @returns Versions parsed from artifact names. + */ +async function localVersions(artifactsRoot: string): Promise { + const entries = await readdir(artifactsRoot).catch(() => []) + return entries.map(entry => ARTIFACT.exec(entry)?.groups?.version) + .filter((version): version is string => version !== undefined && parse(version) !== null) +} + +/** + * Read every version one update bucket publishes for a target. + * @param options - Product version, target, and environment naming the bucket. + * @returns Versions parsed from object names, or undefined when the bucket cannot be listed completely in time. + */ +async function remoteVersions(options: DesktopBuildVersionSuggestionOptions): Promise { + const platform = options.target === 'win-x64' ? 'win32' as const : 'darwin' as const + const arch = options.target === 'mac-arm64' ? 'arm64' : 'x64' + // An unconfigured destination has nothing to be unique against; an invalid one must not be mistaken for it. + if (options.environment[DESKTOP_AUTO_UPDATE_ENV] === undefined + && options.environment.DOWNLOAD_TEST_ORIGIN === undefined) return undefined + const update = resolveDesktopUploadConfig(options.environment, platform, arch) + const secretId = options.environment[update.secretIdEnvName]?.trim() + const secretKey = options.environment[update.secretKeyEnvName]?.trim() + if (secretId === undefined || secretId === '' || secretKey === undefined || secretKey === '') return undefined + // The SDK has no per-request abort, so its own timeout is what releases an unanswered socket. + const cos = createDesktopCos({ secretId, secretKey }, LISTING_DEADLINE_MS) + const deadline = Date.now() + LISTING_DEADLINE_MS + const versions: string[] = [] + let marker: string | undefined + try { + do { + const page = await Promise.race([ + new Promise<{ keys: string[]; next: string | undefined }>((resolveListing, rejectListing) => { + cos.getBucket({ + Bucket: update.bucket, Region: DESKTOP_COS_REGION, MaxKeys: LISTING_PAGE_SIZE, + Prefix: `${update.binaryKeyPrefix}/deepseek-harness-`, ...marker === undefined ? {} : { Marker: marker }, + }, (error, data) => { + if (error !== null && error !== undefined) rejectListing(error instanceof Error ? error : new Error(String(error))) + else { + resolveListing({ + keys: (data?.Contents ?? []).map(object => object.Key), + next: data?.IsTruncated === 'true' ? data.NextMarker ?? data.Contents?.at(-1)?.Key : undefined, + }) + } + }) + }), + new Promise((_resolveDeadline, rejectDeadline) => { + setTimeout(() => rejectDeadline(new Error('listing deadline')), Math.max(0, deadline - Date.now())).unref() + }), + ]) + for (const key of page.keys) { + const version = ARTIFACT.exec(key)?.groups?.version + if (version !== undefined && parse(version) !== null) versions.push(version) + } + marker = page.next + } while (marker !== undefined) + } + catch (unfinished) { + // Numbering against a partial listing could reuse a published index, so an unfinished query yields nothing. + process.stdout.write(`desktop package: the update bucket did not answer completely (${ + unfinished instanceof Error ? unfinished.message : String(unfinished)}); numbering from local artifacts\n`) + return undefined + } + return versions +} + +/** + * Suggest the next build version for today, numbering after what is already taken. + * @param options - Product version, target, and environment to search. + * @returns A validated build version whose index is free. + */ +export async function suggestDesktopBuildVersion(options: DesktopBuildVersionSuggestionOptions): Promise { + const prefix = `${desktopBuildVersionPrefix(options.productVersion)}${options.date ?? desktopBuildDateSegment()}.` + const published = await remoteVersions(options) + const taken = published ?? await localVersions(options.artifactsRoot) + const used = sequenceNumbers(taken, prefix) + const next = used.length === 0 ? 1 : Math.max(...used) + 1 + const suggestion = validateDesktopBuildVersion(`${prefix}${String(next)}`, options.productVersion) + process.stdout.write(`desktop package: numbering ${suggestion} after ${String(used.length)} ${ + published === undefined ? 'local artifact' : 'published build'}${used.length === 1 ? '' : 's'} for ${prefix}*\n`) + return suggestion +} diff --git a/apps/desktop/scripts/desktop-build-version.d.mts b/apps/desktop/scripts/desktop-build-version.d.mts new file mode 100644 index 0000000000..969b601043 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.d.mts @@ -0,0 +1,25 @@ +/** Environment variable that carries the build version through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV: 'DSH_DESKTOP_BUILD_VERSION' + +/** + * Validate a build version against the product version it extends. + * @param buildVersion - Version this build publishes. + * @param productVersion - Version the manifests declare. + * @returns The version as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion: string, productVersion: string): string + +/** + * Resolve the version a build publishes. + * @param env - Packaging or upload environment. + * @param productVersion - Version the manifests declare. + * @returns The build version when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env: NodeJS.ProcessEnv, productVersion: string): string + +/** + * Everything a build version carries before its date, including the trailing separator. + * @param productVersion - Version the manifests declare. + * @returns The prefix shared by every build version of that product version. + */ +export function desktopBuildVersionPrefix(productVersion: string): string diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs new file mode 100644 index 0000000000..e2152ce0d3 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-version.mjs @@ -0,0 +1,100 @@ +/** + * Resolve the version one build publishes, which is not always the version the + * repository declares. + * + * A production release publishes the version in the manifests, aligned with the + * `dsh` npm package. A test build publishes a version that appends a date and a + * sequence number, so one test feed can carry several builds of a single + * product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and + * `0.1.6-test.20260916.1` from a stable one, as the release versions table in + * `apps/desktop/README.md` gives them. Passing that version here keeps it out + * of the manifests, so the tracked version stays the product's while the build + * version reaches electron-builder, the update feed, and the upload validation + * as one input. + * + * `electron-updater` compares feed versions with `semver.gt` against the + * installed `app.getVersion()`, so validation uses the same library. Sequence + * numbers order builds within a feed under either form. The forms differ only + * against the base itself: builds extending a prerelease outrank it, while + * `0.1.6-test.1` ranks below `0.1.6`, which is why the stable form belongs to a + * test feed that never serves the production release. + */ + +import { parse } from 'semver' + +/** Environment variable that carries the build version through one packaging and upload run. */ +export const DESKTOP_BUILD_VERSION_ENV = 'DSH_DESKTOP_BUILD_VERSION' + +/** Prerelease field that opens a test build's suffix on a stable product version. */ +const STABLE_TEST_FIELD = 'test' + +/** + * Parse a version the updater would accept. + * @param {string} version - Version to read. + * @param {string} label - Description used in failures. + * @returns {import('semver').SemVer} The parsed version. + */ +function parseVersion(version, label) { + const parsed = parse(version, { loose: false }) + if (parsed === null) throw new Error(`desktop build version: ${label} ${version} is not a version`) + if (parsed.build.length > 0) { + // Build metadata does not participate in precedence, so two builds would compare equal to the updater. + throw new Error(`desktop build version: ${label} ${version} cannot carry build metadata`) + } + return parsed +} + +/** + * The prerelease fields every build version for one product version starts with. + * @param {import('semver').SemVer} product - Parsed product version. + * @returns {readonly (string | number)[]} Fields a build version must repeat before its date. + */ +function requiredFields(product) { + return product.prerelease.length === 0 ? [STABLE_TEST_FIELD] : product.prerelease +} + +/** + * Validate a build version against the product version it extends. + * @param {string} buildVersion - Version this build publishes. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The version as semver normalizes it, which is what the artifacts will carry. + */ +export function validateDesktopBuildVersion(buildVersion, productVersion) { + const build = parseVersion(buildVersion, 'build version') + const product = parseVersion(productVersion, 'product version') + if (build.version === product.version) return build.version + if (build.compareMain(product) !== 0) { + throw new Error(`desktop build version: ${buildVersion} must extend product version ${productVersion}`) + } + const required = requiredFields(product) + const extendsProduct = build.prerelease.length > required.length + && required.every((field, index) => build.prerelease[index] === field) + if (!extendsProduct) { + throw new Error(`desktop build version: ${buildVersion} must extend ${productVersion} as ${ + desktopBuildVersionPrefix(productVersion)}.`) + } + return build.version +} + +/** + * Resolve the version a build publishes. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The build version when one is present, otherwise the product version. + */ +export function resolveDesktopBuildVersion(env, productVersion) { + const buildVersion = env[DESKTOP_BUILD_VERSION_ENV]?.trim() + if (buildVersion === undefined || buildVersion === '') return productVersion + return validateDesktopBuildVersion(buildVersion, productVersion) +} + +/** + * Everything a build version carries before its date, including the trailing separator. + * @param {string} productVersion - Version the manifests declare. + * @returns {string} The prefix shared by every build version of that product version. + */ +export function desktopBuildVersionPrefix(productVersion) { + const product = parseVersion(productVersion, 'product version') + const [release] = product.version.split('-') + return `${release}-${requiredFields(product).join('.')}.` +} diff --git a/apps/desktop/scripts/desktop-cos.ts b/apps/desktop/scripts/desktop-cos.ts index 68306ffc64..6074d84a05 100644 --- a/apps/desktop/scripts/desktop-cos.ts +++ b/apps/desktop/scripts/desktop-cos.ts @@ -29,9 +29,10 @@ interface CosRequestOptions { * caller names none; that header is removed here so uploading leaves cache policy to deployment * infrastructure. * @param credentials SecretId and SecretKey for the selected deployment. + * @param timeoutMs Request-level inactivity deadline; a caller that must not wait out a transfer passes its own. * @returns A COS client that sends HTTPS requests to the region named by each call. */ -export function createDesktopCos(credentials: DesktopCosCredentials): COS { +export function createDesktopCos(credentials: DesktopCosCredentials, timeoutMs: number = TRANSFER_TIMEOUT_MS): COS { const cos = new COS({ SecretId: credentials.secretId, SecretKey: credentials.secretKey, @@ -41,7 +42,7 @@ export function createDesktopCos(credentials: DesktopCosCredentials): COS { AutoSwitchHost: false, CorrectClockSkew: false, ChunkRetryTimes: 0, - Timeout: TRANSFER_TIMEOUT_MS, + Timeout: timeoutMs, UploadCheckContentMd5: false, }) cos.on('before-send', (options: CosRequestOptions) => { diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 0435235e2f..3addf3ff65 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u -const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -80,6 +81,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = resolveNpmRegistry(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) + else resolveWindowsPackageSettings(environment) if (options.unsigned) return if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) if (target.platform === 'win32') { diff --git a/apps/desktop/scripts/desktop-release-tag.ts b/apps/desktop/scripts/desktop-release-tag.ts new file mode 100644 index 0000000000..6b31938f55 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-tag.ts @@ -0,0 +1,80 @@ +/** + * Record a production release in git after it reaches the download domain. + * + * A build that only goes to a colleague or a test feed carries its commit + * inside the artifact, which is enough to trace it. A build that external users + * install should also be reachable from the repository, so a production upload + * tags the commit it was packaged from. The tag is written after the upload + * succeeds: a tag for a release that never shipped would be worse than none. + * + * Tagging never fails an upload that already completed. The artifacts are + * public by then, and nothing about a missing tag can be repaired by reporting + * the upload as failed; the operator is told what to run instead. + */ + +import { execFileSync } from 'node:child_process' + +/** What tagging one uploaded release did, or why it did nothing. */ +export interface DesktopReleaseTagResult { + readonly tag: string + readonly status: 'created' | 'present' | 'failed' + /** Commands that finish the tagging a failure interrupted, in order. */ + readonly recovery?: readonly string[] + readonly detail?: string +} + +/** + * Name the tag one published version carries. + * @param version - Version the release published. + * @returns The tag name, distinct from the `dsh-v*` tags the npm family publishes from. + */ +export function desktopReleaseTag(version: string): string { + return `desktop-v${version}` +} + +/** + * Tag and push the commit a production release was packaged from. + * @param options - Published version, packaged commit, repository, and remote. + * @returns What tagging did; a failure is reported rather than thrown. + */ +export function tagDesktopRelease(options: { + readonly version: string + readonly commit: string + readonly repositoryRoot: string + readonly remote?: string + readonly run?: (command: string, args: readonly string[]) => string +}): DesktopReleaseTagResult { + const tag = desktopReleaseTag(options.version) + const remote = options.remote ?? 'origin' + const run = options.run + ?? ((command: string, args: readonly string[]) => + execFileSync(command, [...args], { cwd: options.repositoryRoot, encoding: 'utf8' }).trim()) + const create = `git tag ${tag} ${options.commit}` + const push = `git push ${remote} ${tag}` + // A local tag that already exists makes `git tag` fail, so recovery must name only the steps still outstanding. + let tagged = false + try { + const existing = run('git', ['tag', '--list', tag]) + if (existing !== '') { + const named = run('git', ['rev-list', '-n', '1', tag]) + if (named !== options.commit) { + return { tag, status: 'failed', detail: `${tag} already names ${named}, not the uploaded commit ${options.commit}` } + } + tagged = true + run('git', ['push', remote, tag]) + return { tag, status: 'present' } + } + run('git', ['tag', tag, options.commit]) + tagged = true + run('git', ['push', remote, tag]) + return { tag, status: 'created' } + } + catch (error) { + return { + tag, + status: 'failed', + recovery: tagged ? [push] : [create, push], + detail: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/apps/desktop/scripts/desktop-toolchain-preflight.ts b/apps/desktop/scripts/desktop-toolchain-preflight.ts new file mode 100644 index 0000000000..ff855cfd09 --- /dev/null +++ b/apps/desktop/scripts/desktop-toolchain-preflight.ts @@ -0,0 +1,102 @@ +/** + * Probe the external tools a packaging run will shell out to, before it starts. + * + * The packaging sequence reaches its first archive step minutes in, and its + * first installer step later still, so a tool that is absent or behaves + * differently than the scripts assume wastes a whole run. Each probe here + * exercises the specific behaviour the run depends on rather than merely + * checking that the executable resolves. + */ + +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' + +const run = promisify(execFile) + +/** One tool the packaging run requires, and what went wrong with it. */ +export interface DesktopToolchainProbeFailure { + readonly tool: string + readonly detail: string +} + +async function probeTar(): Promise { + // GNU tar reads the colon in an absolute Windows path as a remote-host separator, which the + // release scripts avoid by running beside the archive; this probe confirms the archive reader works there. + const directory = await mkdtemp(join(tmpdir(), 'dsh-tar-probe-')) + try { + const member = join(directory, 'probe.txt') + await writeFile(member, 'probe\n') + const archive = join(directory, 'probe.tar.gz') + await run('tar', ['-czf', basename(archive), basename(member)], { cwd: directory, windowsHide: true, timeout: 20_000 }) + const listed = await run('tar', ['-tzf', basename(archive)], { cwd: dirname(archive), windowsHide: true, timeout: 20_000 }) + if (!listed.stdout.includes('probe.txt')) return `tar listed ${JSON.stringify(listed.stdout)} instead of the archived member` + return undefined + } + catch (error) { + return error instanceof Error ? error.message : String(error) + } + finally { + await rm(directory, { recursive: true, force: true }) + } +} + +async function probeWindowsInstallerToolchain(environment: NodeJS.ProcessEnv): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const programFiles = environment['ProgramFiles(x86)'] + const vswhere = programFiles === undefined + ? undefined + : join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe') + if (vswhere === undefined) { + failures.push({ tool: 'vswhere', detail: 'ProgramFiles(x86) is not set, so Visual Studio cannot be located' }) + } + else { + try { + const located = await run(vswhere, ['-latest', '-products', '*', + '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'], + { windowsHide: true, timeout: 30_000 }) + if (located.stdout.trim() === '') { + failures.push({ tool: 'vswhere', detail: 'Visual Studio C++ build tools are not installed, so the installer helper cannot compile' }) + } + } + catch (error) { + failures.push({ tool: 'vswhere', detail: error instanceof Error ? error.message : String(error) }) + } + } + return failures +} + +/** + * Probe every external tool one packaging run needs. + * @param platform - Target platform; a Windows target already requires a Windows build host. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Every probe that failed, empty when the host can run the packaging sequence. + */ +export async function probeDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures: DesktopToolchainProbeFailure[] = [] + const tar = await probeTar() + if (tar !== undefined) failures.push({ tool: 'tar', detail: tar }) + if (platform === 'win32') failures.push(...await probeWindowsInstallerToolchain(environment)) + return failures +} + +/** + * Probe the toolchain and fail with every problem the host has. + * @param platform - Target platform; a Windows target already requires a Windows build host. + * @param environment - Packaging environment used to locate Windows tooling. + * @returns Resolves when every probe passes. + */ +export async function requireDesktopToolchain( + platform: 'darwin' | 'win32', + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const failures = await probeDesktopToolchain(platform, environment) + if (failures.length === 0) return + throw new Error(`desktop package: the build host cannot run this packaging sequence:\n${ + failures.map(failure => ` ${failure.tool}: ${failure.detail}`).join('\n')}`) +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 0ec76dba3a..e14228cf69 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -13,6 +13,7 @@ import { resolveDesktopUploadConfig, } from './desktop-auto-update-environment.mjs' import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { validateDesktopBuildVersion } from './desktop-build-version.mjs' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -47,6 +48,10 @@ export interface DesktopUploadPlan { readonly secretIdEnvName: string readonly secretKeyEnvName: string readonly artifacts: readonly DesktopUploadArtifact[] + /** Commit the artifacts were packaged from, absent for a package built before builds recorded it. */ + readonly commit?: string + /** Whether that checkout carried uncommitted changes. */ + readonly dirty?: boolean } /** Filesystem and environment inputs used to validate one upload. */ @@ -191,15 +196,24 @@ export async function createDesktopUploadPlan( join(artifactsRoot, desktopBuildRecordFilename(targetName)), `${targetName} package completion record`, ) + // Packaging wrote the version it published; reading it back keeps release settings out of shell variables. + const recordedVersion = stringField(buildRecord.version, `${targetName} package completion record.version`) + let buildVersion: string + try { + buildVersion = validateDesktopBuildVersion(recordedVersion, dshVersion) + } + catch (error) { + throw new Error(`desktop upload: ${targetName} package completion record holds ${recordedVersion}, which is not a build of dsh ${dshVersion}: ${ + error instanceof Error ? error.message : String(error)}`) + } if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName - || buildRecord.version !== dshVersion || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { - throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`) + throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform) + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -210,19 +224,19 @@ export async function createDesktopUploadPlan( } const metadata = object(metadataValue, metadataFilename) const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`) - if (metadataVersion !== dshVersion) { - throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`) + if (metadataVersion !== buildVersion) { + throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match published version ${buildVersion}`) } if (!Array.isArray(metadata.files) || metadata.files.length !== 1) { throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`) } - const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}` + const base = `deepseek-harness-${buildVersion}-${target.os}-${target.arch}` const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe' const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] - const binaryPrefix = `dsh-desk/bin/${targetName}` + const binaryPrefix = update.binaryKeyPrefix if (target.platform === 'darwin') { const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) @@ -254,18 +268,20 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(dshVersion) === null) { + if (prerelease(buildVersion) === null) { const stableFilename = metadataFilename.replace('nightly', 'latest') artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) } return { environment: update.environment, target: targetName, - version: dshVersion, + version: buildVersion, publicUrl: update.publicUrl, bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, artifacts, + ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, + ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 752867f0af..e3873de35f 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,4 +1,5 @@ import { X509Certificate } from 'node:crypto' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -18,6 +19,8 @@ import { scrubWindowsSigningEnvironment, } from './windows-sign.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' +import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs' +import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' @@ -36,6 +39,7 @@ import { * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present. * @param {string} hostArch - Build-host architecture used when no explicit target is present. * @param {string | undefined} preparedRuntime - Verified private dsh tree for installed-update qualification; ordinary releases use the target tree. + * @param {string | undefined} preparedRuntimeVersion - Version that private tree declares, which qualification rewrites away from the product version. * @returns {object} electron-builder configuration. */ export function createElectronBuilderConfig( @@ -43,6 +47,7 @@ export function createElectronBuilderConfig( hostPlatform = process.platform, hostArch = process.arch, preparedRuntime = undefined, + preparedRuntimeVersion = undefined, ) { const appId = resolveDesktopAppId(env) const policy = resolveDesktopPolicyEnvironment(env) @@ -63,7 +68,8 @@ export function createElectronBuilderConfig( let primaryRuntimeDestination let dshDestination let windowsCode = [] - const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg'] + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, @@ -85,13 +91,23 @@ export function createElectronBuilderConfig( } const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime + // electron-builder merges extraMetadata into the packaged manifest, so a build version here reaches + // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. + const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version + const buildVersion = resolveDesktopBuildVersion(env, productVersion) + const packaged = resolveDesktopBuildCommit(env) return { appId, protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], - extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy }, + extraMetadata: { + dshDesktopAppId: appId, + dshMandatoryUpdatePolicy: policy, + ...buildVersion === productVersion ? {} : { version: buildVersion }, + ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, + }, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, + directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, @@ -130,9 +146,12 @@ export function createElectronBuilderConfig( mac: { icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)), category: 'public.app-category.developer-tools', + // macOS matches the application locale against this bundle, not Electron Framework resources. + extendInfo: { CFBundleLocalizations: ['en', 'zh_CN'] }, identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' }, // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], notarize: true, @@ -159,8 +178,10 @@ export function createElectronBuilderConfig( await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish), context.packager.appInfo.updaterCacheDirName) } + // The bundled runtime declares whichever version prepared it: the product version for an ordinary + // release, and a rewritten one for installed-update qualification. await verifyDesktopRuntime(buildPaths.dsh, - context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + preparedRuntimeVersion ?? productVersion, { platform: resolvedPlatform, arch: resolvedArch }) // Unsigned Windows builds skip electron-builder's afterSign hook. if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode) }, diff --git a/apps/desktop/scripts/installed-update-builder.ts b/apps/desktop/scripts/installed-update-builder.ts index c42ae2347b..2f0cbefe6c 100644 --- a/apps/desktop/scripts/installed-update-builder.ts +++ b/apps/desktop/scripts/installed-update-builder.ts @@ -23,7 +23,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi const dsh = join(run.root, version, 'dsh') await verifyDesktopRuntime(dsh, version, { platform: 'win32', arch: 'x64' }) const config = createElectronBuilderConfig({ ...environment, DSH_DESKTOP_APP_ID: run.appId, - DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh) + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh, version) return { ...config, productName: run.productName, directories: { ...config.directories, output: join(run.root, version, 'installer') }, diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index fd3f1c4c36..8d0378ebc0 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -2,14 +2,22 @@ !define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer" !define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui" +ManifestDPIAware true !ifndef BUILD_UNINSTALLER - ManifestDPIAware true !define MUI_CUSTOMFUNCTION_GUIINIT InstallerGuiInit !endif !macro customHeader !define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh" !include "${INSTALLER_STRINGS_FILE}" + !ifdef BUILD_UNINSTALLER + BrandingText " " + SetFont "Segoe UI" 9 + !ifdef LANG_SIMPCHINESE + SetFont /LANG=${LANG_SIMPCHINESE} "Microsoft YaHei UI" 9 + !endif + !include "${INSTALLER_SOURCE_DIR}\uninstall.nsh" + !endif !ifndef BUILD_UNINSTALLER !include "${INSTALLER_SOURCE_DIR}\theme.nsh" !include "${INSTALLER_SOURCE_DIR}\pages.nsh" @@ -74,6 +82,10 @@ Page custom InstallerWelcome InstallerWelcomeLeave !macroend +!macro customUnInstall + Call un.CleanData +!macroend + !macro customPageAfterChangeDir !define MUI_PAGE_CUSTOMFUNCTION_PRE InstallerBeforeInstall !define MUI_PAGE_CUSTOMFUNCTION_SHOW InstallerProgressShow @@ -163,6 +175,8 @@ ${EndIf} !insertmacro InstallerPublishStage 4 !insertmacro dshFinishDirectories + ; Standard uninstall-entry metadata read by inventory tools; the upstream template records it only under its private key. + WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" InstallLocation "$INSTDIR" ${If} $0 == 1 SetErrors ${Else} diff --git a/apps/desktop/scripts/node-entitlements.plist b/apps/desktop/scripts/jit-entitlements.plist similarity index 100% rename from apps/desktop/scripts/node-entitlements.plist rename to apps/desktop/scripts/jit-entitlements.plist diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 4a180838ee..b5d35f1672 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -40,8 +40,9 @@ export async function signMacOSRuntime( const path = files[next++] if (path === undefined) return const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` - const entitlements = path === 'dependencies/node/bin/node' - ? join(import.meta.dirname, 'node-entitlements.plist') : undefined + const needsJit = path === 'dependencies/node/bin/node' + || /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path) + const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined const file = join(root, path) const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file)) if (cacheDirectory !== undefined && policy !== undefined && thin) { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 651bd64cce..62f4fcbbb2 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,6 +1,6 @@ /** Build one release target with matching Electron and dsh architecture. */ -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { parseArgs } from 'node:util' import { join, resolve } from 'node:path' @@ -19,6 +19,10 @@ import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-p import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' import { notarizeMacOS } from './notarize-macos.mjs' import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' +import { DESKTOP_BUILD_VERSION_ENV, resolveDesktopBuildVersion, validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { suggestDesktopBuildVersion } from './desktop-build-version-discovery.ts' +import { desktopBuildCommitEnvironment, readDesktopBuildCommit, resolveDesktopBuildCommit } from './desktop-build-commit.mjs' +import { requireDesktopToolchain } from './desktop-toolchain-preflight.ts' import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts' const APP_ROOT = resolve(import.meta.dirname, '..') @@ -30,6 +34,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [ 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR', + 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', ] as const const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_TEST_COS_SECRET_ID', @@ -38,6 +43,9 @@ const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_PROD_COS_SECRET_KEY', ]) +/** `--build-version` value that numbers a build after the ones already taken. */ +const AUTOMATIC_BUILD_VERSION = 'auto' + /** Fixed platform and architecture identifiers exposed by package scripts. */ export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' @@ -135,15 +143,19 @@ function writeReleaseRecord( if (desktopVersion !== dshVersion) { throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`) } + const buildVersion = resolveDesktopBuildVersion(environment, dshVersion) + const packaged = resolveDesktopBuildCommit(environment) const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name)) const temporaryPath = `${recordPath}.tmp` writeFileSync(temporaryPath, `${JSON.stringify({ schemaVersion: 1, target: target.name, - version: dshVersion, + version: buildVersion, environment: update.environment, publicUrl: update.publicUrl, + // Upload reads this to tag the commit a production release was packaged from. + ...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty }, }, null, 2)}\n`) renameSync(temporaryPath, recordPath) } @@ -185,6 +197,8 @@ interface DesktopPackageInvocation { readonly prepareOnly: boolean readonly unsigned: boolean readonly check: boolean + /** Build identifier to publish under, when this build does not publish the product version. */ + readonly requestedBuildVersion: string | undefined } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -206,25 +220,33 @@ export function parseDesktopPackageInvocation( hostArch: string = process.arch, ): DesktopPackageInvocation { const { values, positionals } = parseArgs({ - args: [...argv], + // `pnpm run ') await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') - const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal()) - expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') + const result = await harness.endpoint().readBytes(harness.scope, './app.js', { baseFile: base }, signal()) + expect(Buffer.from(result.data).toString()).toBe('EXTERNAL_ASSET') }) it.each([ @@ -112,16 +114,41 @@ describe('workspaceFiles.readRelated', () => { await writeFile(join(harness.workspace, 'base'), 'base') const files = harness.endpoint() vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) - const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) + const lstat = harness.ctx.fs.lstat.bind(harness.ctx.fs) + const inspect = vi.spyOn(harness.ctx.fs, 'lstat').mockImplementation((path, options, abort) => + path === 'base' ? lstat(path, options, abort) : Promise.resolve(undefined)) const caller = signal() - await files.readRelated(harness.scope, 'base', './app.js', caller) - expect(read).toHaveBeenCalledWith(harness.scope, expected, caller) + await expect(files.readBytes(harness.scope, './app.js', { baseFile: 'base' }, caller)).rejects.toMatchObject({ code: 'workspace-file/not-found' }) + expect(inspect).toHaveBeenLastCalledWith(expected, { cwd: harness.workspace }, caller) + }) + + it('combines a base file with a bounded range without applying the complete-file cap', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.html'), 'base') + await writeFile(join(harness.workspace, 'nested/asset.bin'), new Uint8Array([0, 255, 128, 4, 5, 6])) + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const caller = signal() + const result = await harness.endpoint({ maxBytes: 2, maxFileBytes: 1 }).readBytes( + harness.scope, './asset.bin', { baseFile: 'nested/base.html', range: { offset: 1, length: 2 } }, caller, + ) + expect([...result.data]).toEqual([255, 128]) + expect(result).toMatchObject({ absolutePath: await realpath(join(harness.workspace, 'nested/asset.bin')), offset: 1, eof: false, bytes: 6 }) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 1, length: 2 }, caller) + expect(completeRead).not.toHaveBeenCalled() + }) + + it('checks the base file kind before reading the target', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.workspace, 'target'), 'target') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'target', { baseFile: 'directory' }, signal()))).code) + .toBe('workspace-file/not-regular-file') }) it('rejects a related symlink rather than following it', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.workspace, 'target'), 'target') await symlink('target', join(harness.workspace, 'link')) - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'link', { baseFile: 'base' }, signal()))).code).toBe('workspace-file/not-regular-file') }) }) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 2af2ca9927..62cb55a218 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -17,18 +17,16 @@ afterEach(async () => { await harness.dispose() }) -const endpoint = (caps?: { maxBytes?: number }): ReturnType => harness.endpoint(caps) +const endpoint = (caps?: { maxBytes?: number; maxFileBytes?: number }): ReturnType => harness.endpoint(caps) /** 256 bytes, each equal to its offset: the window's content names its position. */ const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) -const decode = (data: string): Buffer => Buffer.from(data, 'base64') - describe('workspaceFiles.readBytes — the window', () => { it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data).equals(RAMP)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(RAMP)).toBe(true) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) expect(result.version.length).toBeGreaterThan(0) @@ -36,61 +34,68 @@ describe('workspaceFiles.readBytes — the window', () => { it('cuts the requested window and reports that more follows', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal()) - expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 16, length: 8 } }, signal()) + expect([...result.data]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) }) - it('reads a window of a file far above the byte cap', async () => { + it('reads only a window of a file above both byte caps', async () => { await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) - const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) - expect(decode(result.data)).toHaveLength(1000) + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const caller = signal() + const result = await endpoint({ maxBytes: 1024, maxFileBytes: 4 }).readBytes(harness.scope, 'huge.bin', { range: { offset: 199_000, length: 1024 } }, caller) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 199_000, length: 1024 }, caller) + expect(completeRead).not.toHaveBeenCalled() + expect(result.data).toHaveLength(1000) expect(result).toMatchObject({ eof: true, bytes: 200_000 }) }) it('infers eof from a short window when the backend reports no size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal()) + const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 0, length: 256 } }, signal()) expect(full.eof).toBe(false) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 10 } }, signal()) expect(short).toMatchObject({ eof: true }) expect(short.bytes).toBeUndefined() }) it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal()) + const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 248, length: 8 } }, signal()) expect(exact.eof).toBe(true) - expect(decode(exact.data)).toHaveLength(8) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal()) + expect(exact.data).toHaveLength(8) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 100 } }, signal()) expect(short.eof).toBe(true) - expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) + expect([...short.data]).toEqual([250, 251, 252, 253, 254, 255]) }) it('returns an empty eof window for an offset at or past the end', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal()) - expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 300, length: 8 } }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 300, eof: true, bytes: 256 }) }) it('returns an empty eof window for an empty file', async () => { await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) - const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal()) - expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await endpoint().readBytes(harness.scope, 'empty.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) - it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { + it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip as bytes', async () => { const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) await writeFile(join(workspace, 'blob.bin'), raw) - const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal()) - expect(decode(result.data).equals(raw)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'blob.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(raw)).toBe(true) }) it('names the version a stat of the same file reports', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal()) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) expect(result.version).toBe(stat.version) }) }) @@ -98,22 +103,22 @@ describe('workspaceFiles.readBytes — the window', () => { describe('workspaceFiles.readBytes — defaults and cap', () => { it('defaults the length to the configured byte cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(64) expect(result.eof).toBe(true) }) it('refuses a window longer than the cap as too-large rather than shortening it', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal())) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 65 } }, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 64 }) }) it('accepts a window exactly at the cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 64 } }, signal()) + expect(result.data).toHaveLength(64) }) it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => { @@ -124,7 +129,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, ] for (const range of ranges) { - const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal())) + const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', { range }, signal())) expect(failure.code).toBe('gateway/bad-request') } }) @@ -133,15 +138,15 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { describe('workspaceFiles.readBytes — the gates it shares with read', () => { it('rejects a directory, a missing path, and an empty path', async () => { await mkdir(join(workspace, 'dir')) - expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') - expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', { range: {} }, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', { range: {} }, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(harness.scope, '', { range: {} }, signal()))).code).toBe('gateway/bad-request') }) it('reads a bounded byte window outside the workspace', async () => { await writeFile(join(harness.outside, 'sample.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) - expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) + const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { range: { offset: 2, length: 4 } }, signal()) + expect(result.data).toEqual(RAMP.subarray(2, 6)) expect(result).toMatchObject({ offset: 2, eof: false }) }) }) diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 4ed94f2ea2..005cd77c4c 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.6-alpha.2", + "version": "0.1.7-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 5ae32b4c25..9c69bed474 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.6-alpha.2", + "version": "0.1.7-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 824ff1ff5a..f6ad94fed7 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: f7844725e480aa0f1a6247168882d474301db287 -README.zh.md: 9ba865baec3abaa410bf70fe18418729c8af6851 +README.md: 826d74a8af78048e8dc90bfc04afee78a63e35fd +README.zh.md: c16768dc3510394379ed25d5514c12caa9c0ac9a diff --git a/packages/boot/README.md b/packages/boot/README.md index f7844725e4..826d74a8af 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -25,6 +25,7 @@ The boot group launches profile applications and manages their installed composi | [`app-boot`](app-boot/README.md) | Boots a dsh app from a `cordis.yml`: loads `.env`, applies profile and patch layers, and reports startup failures clearly | (library for the bins) | | [`cmdline`](cmdline/README.md) | Lets the app own its flags, `--help`, and exit code; passes everything after the launcher's flags through verbatim | `cmdlineArgs`, `appExit` | | [`hmr`](hmr/README.md) | Coordinates module and configuration reloads with package mutations | `hmr` | +| [`config-editor`](config-editor/README.md) | Persists active profile configuration through Loader reconciliation | `configEditor` | | [`plugin-manager`](plugin-manager/README.md) | Manages current-profile plugins and bundle packages through shared CLI operations | `pluginManager` | diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 9ba865baec..c16768dc35 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -25,6 +25,7 @@ boot 组负责启动 profile 应用并管理其已安装组合。`app-boot` 解 | [`app-boot`](app-boot/README.zh.md) | 从 `cordis.yml` 启动 dsh 应用:加载 `.env`、应用 profile 与 patch 层,并清晰报告启动失败 | (供各 bin 使用的库) | | [`cmdline`](cmdline/README.zh.md) | 让应用持有自己的 flag、`--help` 与退出码;启动器自身 flag 之后的一切原样传入 | `cmdlineArgs`、`appExit` | | [`hmr`](hmr/README.zh.md) | 协调模块与配置重载,并与包修改互斥执行 | `hmr` | +| [`config-editor`](config-editor/README.zh.md) | 通过 Loader 应用并持久化当前 profile 配置 | `configEditor` | | [`plugin-manager`](plugin-manager/README.zh.md) | 通过共享 CLI 操作管理当前 profile 插件与组合包 | `pluginManager` | diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..f4eaa088e5 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 -README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae +README.md: 5bbe75d641eeeaf3b87c31221b3491ad1408934e +README.zh.md: b3ac89c1cb082880622175b494d540888b19e804 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..5bbe75d641 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -47,7 +47,7 @@ With that entry point, startup keeps every plugin that can activate. An enabled Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data. -A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile