diff --git a/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml new file mode 100644 index 0000000000..8b40d4a6bb --- /dev/null +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.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 +2026-08-11-plugin-settings-tabs.md: 1f1701e000b891b4fc00bc666f603ee00bae50a7 +2026-08-11-plugin-settings-tabs.zh.md: f76a4a9e2317eb6603b48e1a7e451abdc55e00ff diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md rename to .agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md index 96e4c48926..1f1701e000 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md @@ -1,6 +1,7 @@ # Agent Note: Feature-owned tabs in Plugins settings Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-plugin-settings-tabs.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md rename to .agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md index fa8f462640..f76a4a9e23 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -1,6 +1,7 @@ # Agent Note: “插件”设置中的功能自有标签页 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-plugin-settings-tabs.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml new file mode 100644 index 0000000000..4567896a25 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.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 +2026-08-04-large-history-pagination-call-stack.md: 12e9bbf72c2eea2058bf83fe864e09b4a520d391 +2026-08-04-large-history-pagination-call-stack.zh.md: 288687b05ecdfc2858b4d67e1be199135ea20227 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md rename to .agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md index 28c2212112..12e9bbf72c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md @@ -1,6 +1,7 @@ # Agent Note: Large history provenance is scanned without argument expansion Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-04-large-history-pagination-call-stack.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md rename to .agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md index 57dde9bdc0..288687b05e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md @@ -1,6 +1,7 @@ # Agent Note: 大规模历史记录的溯源信息通过扫描处理,不做参数展开 Status: implemented +Archived: 2026-08-22 [English](2026-08-04-large-history-pagination-call-stack.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml new file mode 100644 index 0000000000..a6305c1f5d --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.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 +2026-08-06-plan-narrow-viewport-regression.md: c42a110bf487ffab8f5975e65a8eb9bff4f1b0ae +2026-08-06-plan-narrow-viewport-regression.zh.md: 3df4f8aca57a1830d7f8062cefe76ac1afa9c2ce diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md rename to .agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md index 945d014e0c..c42a110bf4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md @@ -1,6 +1,7 @@ # Agent Note: narrow-viewport plan chip click-area regression test Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md rename to .agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md index 56b3056454..3df4f8aca5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md @@ -1,6 +1,7 @@ # Agent Note: 窄视口下 Plan chip 点击区域回归测试 Status: implemented +Archived: 2026-08-22 [English](2026-08-06-plan-narrow-viewport-regression.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml new file mode 100644 index 0000000000..644cd813ce --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.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 +2026-08-11-preset-card-description-clamp.md: b9088b46184cde6f000fa39afbfe2d1145137b24 +2026-08-11-preset-card-description-clamp.zh.md: a7a3c4f26a55405715fe017ec3a7deb164432b0e diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md rename to .agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md index 16ebf371d5..b9088b4618 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md @@ -1,6 +1,7 @@ # Agent Note: Preset cards clamp their description instead of sizing the roster Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-preset-card-description-clamp.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md rename to .agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md index 5b7a18f41e..a7a3c4f26a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md @@ -1,6 +1,7 @@ # Agent Note: 预设卡片截断自身描述,而不是由描述决定整份名单的高度 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-preset-card-description-clamp.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml new file mode 100644 index 0000000000..87a43d82ca --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.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 +2026-07-30-versioned-gui-welcome-onboarding.md: 370793dd4e6d744ea61fc9319a94728dbbf3e1fe +2026-07-30-versioned-gui-welcome-onboarding.zh.md: 7b99377d00127174d5bfd8d080107c2cb67e6fff diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md rename to .agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 9c8684c051..370793dd4e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -1,6 +1,7 @@ # Agent Note: Versioned GUI welcome onboarding Status: implemented +Archived: 2026-08-22 English | [中文](2026-07-30-versioned-gui-welcome-onboarding.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md rename to .agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index d4fe40a1af..7b99377d00 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -1,6 +1,7 @@ # Agent Note: 版本化 GUI 欢迎引导 Status: implemented +Archived: 2026-08-22 [English](2026-07-30-versioned-gui-welcome-onboarding.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml new file mode 100644 index 0000000000..4cb64e37c0 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.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 +2026-08-06-bundled-dsh-badge-skill.md: 2909736d53f8aff41ca69e705de44657bc1b4f1e +2026-08-06-bundled-dsh-badge-skill.zh.md: de85e9476d3945cd13335ef596243bc2a126ae55 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md similarity index 98% rename from .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md rename to .agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md index 4c6fbdcb76..2909736d53 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -1,6 +1,7 @@ # Agent Note: Bundled dsh badge skill Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md similarity index 98% rename from .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md rename to .agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md index fe291d4986..de85e9476d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -1,6 +1,7 @@ # Agent Note: 内置 dsh 徽章 skill Status: implemented +Archived: 2026-08-22 [English](2026-08-06-bundled-dsh-badge-skill.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml new file mode 100644 index 0000000000..7459c96dae --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.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 +2026-08-07-workspace-picker-composer-entry.md: 023cbe09dd75015a1555103642d1b66ce75aafc9 +2026-08-07-workspace-picker-composer-entry.zh.md: 6b84885b11fb5372a51d620b40ea7d24fe7e45a2 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md rename to .agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md index dc9c26c291..023cbe09dd 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md @@ -1,6 +1,7 @@ # Agent Note: The no-Workspace composer opens the existing picker Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-07-workspace-picker-composer-entry.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md rename to .agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md index 9121750ae1..6b84885b11 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -1,6 +1,7 @@ # Agent Note: 未选择 Workspace 时从编辑器打开现有选择器 Status: implemented +Archived: 2026-08-22 [English](2026-08-07-workspace-picker-composer-entry.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml new file mode 100644 index 0000000000..27eba69e62 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.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 +2026-08-10-creator-guidance-introduce-cue.md: 2954bb9dca6bd5359ab3ed4b7e32bd8336709a10 +2026-08-10-creator-guidance-introduce-cue.zh.md: 4f818b3a444cbc7bbd4355ac8e7a883aaa4bfa51 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md rename to .agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md index 888fee7b3d..2954bb9dca 100644 --- a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md @@ -1,6 +1,7 @@ # Agent Note: Creator guidance lands as an introduce cue on the preset chip Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md rename to .agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md index d80260abd1..4f818b3a44 100644 --- a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md @@ -1,6 +1,7 @@ # Agent Note: 创造模式引导以介绍动效落在预设 chip 上 Status: implemented +Archived: 2026-08-22 [English](2026-08-10-creator-guidance-introduce-cue.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml new file mode 100644 index 0000000000..6fc55e3a3e --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.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 +2026-08-11-collapsible-ask-user-question-card.md: 08e87b0d1f05f47c5bc87ef9b32cab05ef229e5c +2026-08-11-collapsible-ask-user-question-card.zh.md: fd3b838ff174dcdb3d4994fe30b193d63f5c4d15 diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md rename to .agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md index 5c7e62749e..08e87b0d1f 100644 --- a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md @@ -1,6 +1,7 @@ # Agent Note: Collapsible Ask-User Question Card Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-collapsible-ask-user-question-card.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md rename to .agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md index 5f4b5851e4..fd3b838ff1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md @@ -1,6 +1,7 @@ # Agent Note: 可收起的提问卡片 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-collapsible-ask-user-question-card.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml new file mode 100644 index 0000000000..da756e7493 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.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 +2026-08-11-web-export-command-and-dialog.md: aba9048f26237ea01e861a5ee6e31b89429629c8 +2026-08-11-web-export-command-and-dialog.zh.md: be4a3a53b1ff75cfbe176e258fb6a73e5abfee22 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md rename to .agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md index d28925500a..aba9048f26 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md @@ -1,6 +1,7 @@ # Agent Note: Web `/export` shares the streamed Session ZIP download Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-web-export-command-and-dialog.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md rename to .agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md index a9a9c4a5cc..be4a3a53b1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web `/export` 共用流式 Session ZIP 下载 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-web-export-command-and-dialog.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..d001e4a95c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.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 +2026-08-18-web-home-path-tilde.md: 108674740c804a42ec3b0491505186215a9d9fcd +2026-08-18-web-home-path-tilde.zh.md: 1f742158f62b9b7fbb8eae8be35c13adc95f3a09 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md rename to .agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md index b148833bab..108674740c 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md @@ -1,6 +1,7 @@ # Agent Note: Web UI abbreviates POSIX home paths as `~` Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-18-web-home-path-tilde.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md rename to .agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md index 9d15cd6dca..1f742158f6 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web UI abbreviates POSIX home paths as `~` Status: implemented +Archived: 2026-08-22 [English](2026-08-18-web-home-path-tilde.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml new file mode 100644 index 0000000000..fbbf621db9 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.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 +2026-08-19-high-cache-hit-decimal-display.md: 83c031bd1049ec26029d2e9ba0dc5cd623c3f867 +2026-08-19-high-cache-hit-decimal-display.zh.md: 62f27e39d37cf2445ac6796030092e892d82b581 diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md rename to .agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md index 952d838fdf..83c031bd10 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md @@ -1,6 +1,7 @@ # Agent Note: High cache-hit decimal display Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md rename to .agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md index ba33bd8a26..62f27e39d3 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md @@ -1,6 +1,7 @@ # Agent Note: 高缓存命中率的小数显示 Status: implemented +Archived: 2026-08-22 [English](2026-08-19-high-cache-hit-decimal-display.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 2c9391bc96..aa76eab57c 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -43,6 +43,9 @@ "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", + "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", + "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", + "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", @@ -94,9 +97,18 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml": "sha256:9bb1ceec013521116ee73eb9ec28c5708ae9520d6e53dcd4a3621fd2283b215c", + "bug-fix/2026-08-04-large-history-pagination-call-stack.md": "sha256:38c5afd347b131abd6b73634d25210d593bcb1fd72c7ba501bad0b33fb639810", + "bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md": "sha256:2a2790b3b3400c747e20b998edfa96788b4035ccfa5fd4100dbc9a0e694ed30e", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml": "sha256:fe0539da9ce4015c6deaf585350e586e99d86e0073a36e131b6f1f62cc13382b", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.md": "sha256:ccecdf52213dd1f6ab9935db31906520b83a7d9166f612376877d612230d1331", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md": "sha256:be10805f0cd5a4f0812c883ab7f3e1e9396b579be455696d5cd726434a26b3e1", "bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3", "bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6", "bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d", + "bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml": "sha256:d50452503b59aa22c81617888f9391f31f12a779c4b18efb2b4b6de1bd9702a6", + "bug-fix/2026-08-11-preset-card-description-clamp.md": "sha256:7eb8db697f3ad3dea8c0a6045331c05730a010404a89e2b08348cb7b0fad26c8", + "bug-fix/2026-08-11-preset-card-description-clamp.zh.md": "sha256:6d2f7b55ce02275a45adfdd853805e7daf2d6534929b77beb86685a54fc34f85", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1", @@ -241,6 +253,9 @@ "feature/2026-07-30-tui-details-command.i18n.yaml": "sha256:033cea6df0a16fc68cbdb435babdc6e75c1199a8e70e1a71d87c800c40f5a044", "feature/2026-07-30-tui-details-command.md": "sha256:a13478d4e55ec6d358209b51b541413ec75d0e20dfc22196ace28020f03f0c2d", "feature/2026-07-30-tui-details-command.zh.md": "sha256:de9c449b98468cef34ce4f9a9d2a854a5d8905eecd61f80e27a9a0e4495e9901", + "feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml": "sha256:3d453f1a8f1a642ed569d1900009b785e582614bcabf9fede566ecbd9842e3ef", + "feature/2026-07-30-versioned-gui-welcome-onboarding.md": "sha256:cfaa38cfec722ac3792a4770f7733372805a6c0571f4f08519f367976b0d2379", + "feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md": "sha256:0ce0e4616580c583725aa3d28063dc009889d7f0a260a3cdda53ff48721c1ae4", "feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml": "sha256:4c3fc380b0512ad7c00baacd0ac610e1a78ae45374311d9bd43bab6b5e29e630", "feature/2026-07-30-versioned-tui-first-run-welcome.md": "sha256:296f153e6c839f3743078e4f5aab3b2befc211c934835238668c57bdeae52231", "feature/2026-07-30-versioned-tui-first-run-welcome.zh.md": "sha256:82871a9cca1fec46bb08a5b39daad28a44bb2419dea367b4ae41af3cf07bfa65", @@ -259,9 +274,30 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml": "sha256:4b568d89976a71b7b3864e13b36925bf055479213739ffd4ac81614e00e93e36", + "feature/2026-08-06-bundled-dsh-badge-skill.md": "sha256:7b67f7c09b7e2b2ca756983a8951dad3a15786b8cd3adc6e819f316c67d31b2c", + "feature/2026-08-06-bundled-dsh-badge-skill.zh.md": "sha256:dcc0acb2dca596196ac034a8e86a644131c5b7fb09b184ec9d8493f93019d2b1", + "feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml": "sha256:24a8bb2956371c7c840a662ac16dffbe04a6bb40a7296d01db86cb85da58d238", + "feature/2026-08-07-workspace-picker-composer-entry.md": "sha256:036212fbae6f5d7194e8c7fc9b1e7cd1c35251e9c227e895834a7d00bd5f69f8", + "feature/2026-08-07-workspace-picker-composer-entry.zh.md": "sha256:fb65c3330e8324f90d1270345e1ac941fc800e8caaf3b4bbee1bbb743f713262", "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", + "feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128", + "feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f", + "feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162", + "feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml": "sha256:9c0873bbb1437bcd5025f5859e1dc447a6b936f19c2c2ad2250521a3aa773a12", + "feature/2026-08-11-collapsible-ask-user-question-card.md": "sha256:4f3b3f5d7020fefbbac8a3c36a97642721ef14a0476a7d8117bde8a128d25f42", + "feature/2026-08-11-collapsible-ask-user-question-card.zh.md": "sha256:e7186c92f77d337a875f981ae39b16331a1c5466a948415bcacfe108ad98fb63", + "feature/2026-08-11-web-export-command-and-dialog.i18n.yaml": "sha256:db7d523a2a1f82a86f532661bd2953ee8538d971d91f886e4bd4e0d88f7226b2", + "feature/2026-08-11-web-export-command-and-dialog.md": "sha256:ec44b47589ca7924018dc24f7fa73379a97b8f053d9e8ccce2aebb600230e47b", + "feature/2026-08-11-web-export-command-and-dialog.zh.md": "sha256:ad28e67d397c87300cfe1705ba3d206cc4d054e07f5647c095c718ac8cf4ec98", + "feature/2026-08-18-web-home-path-tilde.i18n.yaml": "sha256:f151e3e3514f59784fc646c2feb3075dc954c65110d48c2cc482ad486fc0b86f", + "feature/2026-08-18-web-home-path-tilde.md": "sha256:8c7ecf120ff8c81826160acab5fc906a2a0a14213bcd2958343cfea47328d68e", + "feature/2026-08-18-web-home-path-tilde.zh.md": "sha256:3486c5b42aed5bcadf12c62c5e1e6cf7c1b493fc1085ad7d154cdf2ec34076cc", + "feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml": "sha256:c2cb839ed676040ed62153c2aab65b677fa59739f69253af50246e79a2347620", + "feature/2026-08-19-high-cache-hit-decimal-display.md": "sha256:08cb68bfc379da47a05b816afac26126146d36d6248350d4380f7cb98607d573", + "feature/2026-08-19-high-cache-hit-decimal-display.zh.md": "sha256:9d7afe3e2fc3029fbccc643b432a01bcb3b5671750adb6945ac414ec843ca063", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", @@ -322,6 +358,9 @@ "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d", "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857", "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032", + "process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730", + "process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b", + "process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", @@ -406,6 +445,18 @@ "simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml": "sha256:5466161f3fb8f2e8117fe8ff242675cc9fe9ef264d1e29b9bc586891c73c051a", "simplification/2026-08-03-explicit-config-dsh-entrypoint.md": "sha256:f23accae7d05c2e75cb73ec69b492307f1ce7526ecfa9f6b12a621e02fd1a0c3", "simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md": "sha256:a32d2c6ecf748a16a2c35b59cd2da2fda75769e3ab24be6a2e026d8655466db4", + "simplification/2026-08-11-cmdline-program-action.i18n.yaml": "sha256:e33b6dee66e23beabf03275e4e4f15134d23a82740ae7be6afd28c11c3163fca", + "simplification/2026-08-11-cmdline-program-action.md": "sha256:e6a274bd92a35c98ea24704161b408507876de3162c0f640b4bc70a86ab4d86f", + "simplification/2026-08-11-cmdline-program-action.zh.md": "sha256:bd213ad65ea6129c5360f28b2f52e6f3e224a58d07f56da190702939e7b402ee", + "simplification/2026-08-11-quickstart-documentation-home.i18n.yaml": "sha256:548c0ff16d40fed3b3318b0b9a26e11d53a60582ff457098a212fc64e7d67eac", + "simplification/2026-08-11-quickstart-documentation-home.md": "sha256:21946a828417aca4a214a874a35e88fe5a3e5989c330421f3849937b35bb9a9b", + "simplification/2026-08-11-quickstart-documentation-home.zh.md": "sha256:cb292a428d427cf36aff0a184347f0ab653331edb874daf3c5b58a0d1f8e6964", + "simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml": "sha256:51267b74e39544991bfe606e3f749a26914162e454cf357f26223a6ed8de5fad", + "simplification/2026-08-13-remove-first-run-beta-notice.md": "sha256:7ef5c712b8dff1152becee6a3f800acd5f7589d7b000f01544f57b175660bcc1", + "simplification/2026-08-13-remove-first-run-beta-notice.zh.md": "sha256:f899c79f838b97d1eea4a118de2cf00a97bae910d86d4aabdc447b4e02fb585f", + "simplification/2026-08-19-knip-config-cleanup.i18n.yaml": "sha256:ca8f5726aed57ce376c3fbd8b70113e235f3ba37dbf290683157fb4bf143ab13", + "simplification/2026-08-19-knip-config-cleanup.md": "sha256:18c61713b3358d3019dac096afc26ce8e5189002f626b25e858dfc5e6f626c8d", + "simplification/2026-08-19-knip-config-cleanup.zh.md": "sha256:b8ff16089c1a331603bb80278544c637cb16c1fa3bcfca3c5e1187152a1a0947", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", @@ -429,6 +480,9 @@ "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa", "testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0", "testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3", - "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b" + "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b", + "testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml": "sha256:741e7e58e5e8a9c82d901c4a16a70cea9bd256eac0e94179b5a24a231bb9fe1f", + "testing/2026-08-12-required-python-runtime-pull-request-ci.md": "sha256:1f1273d7a550667533e29c76efd148aebf57581a91729c877b44a5e43a52d9ad", + "testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md": "sha256:6b9bf126c6b83d9b21e135d38df677c0d5623168b4353c6ddb706f76762c2193" } } diff --git a/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml new file mode 100644 index 0000000000..439c0c1d47 --- /dev/null +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.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 +2026-08-12-documentation-site-navigation-and-chrome.md: f33f017d54bcbb3583f37be27dcd6c69952bc66a +2026-08-12-documentation-site-navigation-and-chrome.zh.md: 7f3ff5c829c561167d8e2475cd1c2adf050975be diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md rename to .agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md index 03cd44b94f..f33f017d54 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -1,6 +1,7 @@ # Agent Note: Documentation-site navigation and repository chrome Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md rename to .agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index d0972f909e..7f3ff5c829 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -1,6 +1,7 @@ # Agent Note: 文档站导航与仓库 chrome Status: implemented +Archived: 2026-08-22 [English](2026-08-12-documentation-site-navigation-and-chrome.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..69735dfe18 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.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 +2026-08-11-cmdline-program-action.md: 96cbe2342eef90e68dece3c78b12a2de1bbea7c0 +2026-08-11-cmdline-program-action.zh.md: f5a9fea1447c78c9f099d3b54acd5b90a21aa503 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md rename to .agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md index 40c4dae1d3..96cbe2342e 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md @@ -1,6 +1,7 @@ # Agent Note: parseCmdline runs the program's own commander action Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-cmdline-program-action.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md rename to .agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md index 1eb9746162..f5a9fea144 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md @@ -1,6 +1,7 @@ # Agent Note: parseCmdline 运行 program 自己的 commander action Status: implemented +Archived: 2026-08-22 [English](2026-08-11-cmdline-program-action.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml new file mode 100644 index 0000000000..9ad4e8aacb --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.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 +2026-08-11-quickstart-documentation-home.md: 653c702eba4c90e52ee0539959d926ae23a98e6c +2026-08-11-quickstart-documentation-home.zh.md: 3d21566f9e4a822d095a115a5e65bfbaa3f947df diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md rename to .agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md index 3fd98843fc..653c702eba 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md @@ -1,6 +1,7 @@ # Agent Note: Route documentation roots to quick start Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-quickstart-documentation-home.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md rename to .agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md index 63871b01c4..3d21566f9e 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md @@ -1,6 +1,7 @@ # Agent Note: 将文档根路由指向快速开始 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-quickstart-documentation-home.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml new file mode 100644 index 0000000000..abe87647e3 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.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 +2026-08-13-remove-first-run-beta-notice.md: 535d0a20a5805c137551e6047f40fc5cf53153b8 +2026-08-13-remove-first-run-beta-notice.zh.md: 20626bbd9d0bd13c00d4ee66f5dbc267c2e92082 diff --git a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md rename to .agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md index 21396eb9cc..535d0a20a5 100644 --- a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md @@ -1,6 +1,7 @@ # Agent Note: Remove the first-run beta notice Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-13-remove-first-run-beta-notice.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md rename to .agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md index c7c31b163d..20626bbd9d 100644 --- a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除首次启动内测声明 Status: implemented +Archived: 2026-08-22 [English](2026-08-13-remove-first-run-beta-notice.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.i18n.yaml b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.i18n.yaml new file mode 100644 index 0000000000..6612299cc4 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.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 +2026-08-19-knip-config-cleanup.md: 56426aeb7ff53caf7828b3f252269559e596940d +2026-08-19-knip-config-cleanup.zh.md: a74fa831f2301d9b95d5e34b003585293501c874 diff --git a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md rename to .agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md index 629e64be79..56426aeb7f 100644 --- a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md @@ -1,6 +1,7 @@ # Agent Note: Deleted stale and duplicative knip.json workspace entries Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-19-knip-config-cleanup.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.zh.md b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.zh.md rename to .agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md index e7a1ec9121..a74fa831f2 100644 --- a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.zh.md +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md @@ -1,6 +1,7 @@ # Agent Note: 删除 knip.json 中失效与重复的 workspace 条目 Status: implemented +Archived: 2026-08-22 [English](2026-08-19-knip-config-cleanup.md) | 中文 diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml similarity index 66% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml index 773ac10950..d6f71ad4c3 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.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/testing/2026-08-12-required-python-runtime-pull-request-ci.md -2026-08-12-required-python-runtime-pull-request-ci.md: 61b1e832be6d29eafe5cb304d2bca3f0a59e3d84 -2026-08-12-required-python-runtime-pull-request-ci.zh.md: 1702af710837c45094711cf52e88bd54d71f7171 +2026-08-12-required-python-runtime-pull-request-ci.md: e7da767f22634bd50bc4fd38b1de34677c4124e7 +2026-08-12-required-python-runtime-pull-request-ci.zh.md: 702125b0da864eb35f1fe870748cc0e314b01a39 diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md similarity index 99% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md index 61b1e832be..e7da767f22 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md @@ -1,6 +1,7 @@ # Agent Note: Required Python runtime pull-request validation Status: implemented +Archived: 2026-08-23 English | [中文](2026-08-12-required-python-runtime-pull-request-ci.zh.md) diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md similarity index 99% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md index 1702af7108..702125b0da 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md @@ -1,6 +1,7 @@ # Agent Note: 必需的 Python 运行时拉取请求验证 Status: implemented +Archived: 2026-08-23 [English](2026-08-12-required-python-runtime-pull-request-ci.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index b7a0ea7192..1c4eadabc1 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.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-13-capability-seams.md -2026-06-13-capability-seams.md: 2a166278ea454895177fa12b58f5493276f19cd1 -2026-06-13-capability-seams.zh.md: 28b45cbbc7f65a0b783db3d91a2e559132b5779f +2026-06-13-capability-seams.md: 46a2c39e927e859c7eb95956d8586f3bf04c7b1c +2026-06-13-capability-seams.zh.md: f44e3e68d2153149435b0fd0aaa5fd121cf3ecad diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md index 2a166278ea..46a2c39e92 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md @@ -6,7 +6,7 @@ English | [中文](2026-06-13-capability-seams.zh.md) ## Problem -The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. +The harness has swappable capabilities, including shell execution and model providers. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. This is distinct from "who provides vs. needs a capability at runtime", which Cordis already answers with services + `inject` (a provider registers `ctx.shell`; a consumer declares `inject: ['bash']` and its fiber pends until the service exists). That mechanism is necessary but doesn't dictate package boundaries; this Agent Note does. diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index 28b45cbbc7..f44e3e68d2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*约定*(这项能力是什么)、*实现*(它如何运行)、*消费方 API*(模型和其他插件面向什么编程)。将三者捆绑在一个包中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的约定从未改变。 +harness 具有可替换的能力,包括 shell 执行和模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*约定*(这项能力是什么)、*实现*(它如何运行)、*消费方 API*(模型和其他插件面向什么编程)。将三者捆绑在一个包中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的约定从未改变。 这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过服务 + `inject` 解决(提供方注册 `ctx.shell`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 8097b8cbb0..5148c8a648 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd -2026-06-18-shared-persistence-write-coordinator.zh.md: 70db616b0a71826c648072228fff936ad423ad8f +2026-06-18-shared-persistence-write-coordinator.md: 8392ec726ff44e8a7173f48ef7d5cc4826b7e882 +2026-06-18-shared-persistence-write-coordinator.zh.md: e160f29247ae5cd02aaa8388c141faec64001857 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 286bbb7d5c..8392ec726f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -24,11 +24,12 @@ The coordinator retires a session from `session/disposed`: it waits for the cont ### The hook interface (`PersistenceBackend`) -Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: +Five required members plus optional empty-materialization and lifecycle hooks form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. - `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. -- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). +- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized. Ordinary creation therefore cannot leave an abandoned materialized-but-empty session. +- `materializeHeader?(meta)` — explicitly persist a header-only session for `SessionPersistence.ensureMaterialized(session)`. This is reserved for a lifecycle frontend that treats an empty session itself as a resumable durable resource; [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) are the first consumer. Backends that support that lifecycle implement the hook; lazy creation remains the default. - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `prepare`/`load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 70db616b0a..e160f29247 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -24,11 +24,12 @@ Status: implemented ### 钩子接口(`PersistenceBackend`) -五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: +五个必需成员加可选的空会话实体化与生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 - `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 -- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话。因此,普通创建不会留下被放弃的已物化空会话。 +- `materializeHeader?(meta)`——为 `SessionPersistence.ensureMaterialized(session)` 显式持久化仅含 header 的会话。它只供把空会话本身视为可恢复持久资源的生命周期前端使用;[标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)是第一个 consumer。支持该生命周期的后端实现此钩子;惰性创建仍是默认行为。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index ed11425f4b..d929b209e3 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.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-branded-ids.md -2026-06-20-branded-ids.md: 29b258b21240c92e74051339f0939a8e70933099 -2026-06-20-branded-ids.zh.md: 2f960bac3172f1e83161f1af8f4e9d2cc0cda7bd +2026-06-20-branded-ids.md: dda97bbf546ef99083cbe3bd2c7da39070407e04 +2026-06-20-branded-ids.zh.md: 82b79dff9d5018e2ea9f9969148eb25225f6d727 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index 29b258b212..dda97bbf54 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -6,7 +6,7 @@ English | [中文](2026-06-20-branded-ids.zh.md) ## Problem -The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-job id is a plain `string`: `BashTask.id: string` (`packages/shell/shell/src/types.ts`), carried as `string` through the whole executor seam (`ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/shell/shell/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateJobId`, `assertTaskAccess`, the `job_id` schema arg in `packages/shell/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/shell/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `job_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. @@ -56,7 +56,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **`ToolName`** (the `ToolRuntime` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. - **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. -- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change. +- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 2f960bac31..82b79dff9d 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 @@ -56,7 +56,7 @@ export function OwnerToken(id: string): OwnerToken { - **`ToolName`**(`ToolRuntime` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 - **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 -- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。 +- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 1b5420b35a..2b6c63d334 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 479d3a46dc41c5cc9ae9b77b81dbef3d6524370b -2026-06-21-mandatory-app-attribution-headers.zh.md: 1b11cb6ef1e96609c6777134a85de298ca979c58 +2026-06-21-mandatory-app-attribution-headers.md: 9e0c029dc03c722512680a563c24e470128ee322 +2026-06-21-mandatory-app-attribution-headers.zh.md: 1427daf8f6065ec2dee324075a8381a625d9e960 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 479d3a46dc..9e0c029dc0 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -42,7 +42,7 @@ Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field |---|---| | All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. | | Direct DeepSeek endpoint | `User-Agent` for app attribution; `x-deepseek-harness-user-id` and conditional `x-deepseek-harness-session-id` are separate request identity under the DeepSeek-specific decision. Do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. | -| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this decision. | +| OpenRouter endpoints | `User-Agent` only. This decision excludes `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories`. | | Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. | Endpoint detection is not part of this Agent Note because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 1b11cb6ef1..1427daf8f6 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -42,7 +42,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` |---|---| | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 直连 DeepSeek 端点 | `User-Agent` 用于应用归属;`x-deepseek-harness-user-id` 与条件性的 `x-deepseek-harness-session-id` 由 DeepSeek 特有决策作为独立请求身份管理。除非 DeepSeek 文档化了等效约定,否则不发送 OpenRouter 特有头部。 | -| OpenRouter 端点 | 目前仅 `User-Agent`。本决策下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | +| OpenRouter 端点 | 仅发送 `User-Agent`。本决策排除 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 与 `X-OpenRouter-Categories`。 | | 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 | 端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index b2478911dc..23c4ea4142 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 63146fa2d392a45543daa32ce2b00158782fddb2 -2026-07-05-reconstructable-requests.zh.md: 94c1d323be0107eb8b6072a05d1e8832ebd1fffc +2026-07-05-reconstructable-requests.md: 3f49ba71a6b98a84b05530c900e902b0cf9f6449 +2026-07-05-reconstructable-requests.zh.md: 7b8a9df65b60f975bc3ae60b2c1b0c3a8cc22e95 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 63146fa2d3..3f49ba71a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -51,5 +51,6 @@ Like MiniCode, the conversation advances append-only and resets only when model- - What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. - Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. +- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 94c1d323be..7b8a9df65b 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -51,5 +51,6 @@ Status: implemented - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 - 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 +- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 580e74f217..66dd29f3a4 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.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-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a -2026-07-06-timeout-deadline-library.zh.md: c8d189c2a7588ee57b0f7fe02137b78c9ad7ff9d +2026-07-06-timeout-deadline-library.md: 95adc41bffff6d7711685ebc52cb73b2b455df41 +2026-07-06-timeout-deadline-library.zh.md: 8b7b18a2d1e7757102afc81bea03245de2707d86 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 38f048d16e..95adc41bff 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index c8d189c2a7..8b7b18a2d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml index 398eaab528..554114fff0 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.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-06-tool-result-retention-library.md -2026-07-06-tool-result-retention-library.md: 8d938db8f5fa78398a39e97cc877308200f7d60f -2026-07-06-tool-result-retention-library.zh.md: 747b545ee8c900c13d80c7aef6caecc8ae8dc0ad +2026-07-06-tool-result-retention-library.md: 464e3d51a0051487b7c29f0c01a11acb91d160e5 +2026-07-06-tool-result-retention-library.zh.md: 49361eec4b649d2d68dc36929baa0f9ff580eb68 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index 8d938db8f5..464e3d51a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -16,7 +16,7 @@ The shared abstraction the tools need is **retention**, not generic collection. The library has two independent retainers: -- `ItemRetainer` handles ordered logical units such as paths, grep matches, or search sources. It supports `head` retention only in v1, while keeping the retainer shape open to additional retention strategies later. +- `ItemRetainer` handles ordered logical units such as paths, grep matches, or search sources. It supports only `head` retention, while keeping the retainer shape open to additional strategies. - `TextRetainer` handles byte-oriented text streams such as bash stdout/stderr or web response bodies. It supports `head`, `tail`, and `headTail` retention while preserving UTF-8 boundaries at `finish()`. Both retainers return a small `PushDecision` after each `push()` so callers can tell whether that unit/chunk was fully retained and whether the accumulated result is now truncated. Omission counts are exact because callers keep feeding every observed item/chunk. @@ -95,7 +95,7 @@ type TextRetentionStrategy = ### Tool mapping -`read` is intentionally outside the v1 retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`. +`read` is intentionally outside the retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`. `FsGlobEntry` and `FlatGrepMatch` below are the intended discovery-tool item shapes, not existing retention-library exports. `FsGlobEntry` is one backend-derived path, and `FlatGrepMatch` is one ungrouped grep match before the backend groups retained matches by file. @@ -142,15 +142,15 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into **Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording. -**Tradeoffs accepted.** The v1 API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. +**Tradeoffs accepted.** The API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. ## Alternatives considered **Post-hoc `truncate(text)` only.** Rejected: it matches Codex's history/tool-output truncation use case but loses item counts, grouping boundaries, UTF-8-safe byte windows, and exact omission metadata. -**One generic `Collector` with pluggable callbacks.** Rejected for v1: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small. +**One generic `Collector` with pluggable callbacks.** Rejected: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small. -**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. +**Put `read` windowing behind `ItemRetainer`.** Rejected: `read` is the only shipped window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. **Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used by a tool's Native renderer; the model-facing projection remains tool-owned while the [canonical value](2026-07-20-canonical-tool-output-contract.md) may retain the complete acquired result. diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md index 747b545ee8..49361eec4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -16,7 +16,7 @@ Status: implemented 该库包含两个相互独立的 retainer: -- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 +- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。它只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 - `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。 两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。 @@ -95,7 +95,7 @@ type TextRetentionStrategy = ### 工具映射 -`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 +`read` 被有意排除在保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。 @@ -142,15 +142,15 @@ const formatGrepNotice = (notice: RetentionNotice): string => **该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash spill 路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 -**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 +**接受的取舍。**接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 ## 考虑过的替代方案 **只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。 -**使用一个带可插拔回调的通用 `Collector`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 +**使用一个带可插拔回调的通用 `Collector`。**不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 -**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 +**把 `read` 窗口交给 `ItemRetainer`。**不予采纳:`read` 是唯一已交付的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 **让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.zh.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 15abdc91f0..5ba89a4412 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: 4d1d4b7b665f34b362df2f8c8aeb06d96bf2668f -2026-07-08-tool-output-spill-files.zh.md: 3e7ce4f57a0078c6a4b919436946be8e172fa7cb +2026-07-08-tool-output-spill-files.md: 14667b74ca877622d05196e9bf83945a842fe366 +2026-07-08-tool-output-spill-files.zh.md: db297fa6bee707a1d5a10d20260ce6b8a660d207 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 4d1d4b7b66..14667b74ca 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 @@ -147,8 +147,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa ## Non-goals -- No new model-facing `artifact_read` or `artifact_search` tool in v1. -- No per-tool retention configuration in v1. +- This decision adds no model-facing `artifact_read` or `artifact_search` tool. +- This decision adds no per-tool retention configuration. - No model-facing timeout/truncation arguments. - No migration of `read` output into spill files. - No replacement for provider/resource caps such as `web-fetch-http.maxBodyChars`. @@ -174,9 +174,9 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work. -Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators. +Returning real paths keeps the local backend simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators. -The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader. +The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader. **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. @@ -184,7 +184,7 @@ The policy can become too large if it starts owning tool-specific semantics. It ## Alternatives considered -**Require each tool to opt in with a retention declaration.** Rejected for v1: 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 `maxInlineBytes` 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 3e7ce4f57a..db297fa6be 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 @@ -147,8 +147,8 @@ ctx.tools.register(defineTool({ ## 非目标 -- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 -- v1 不增加逐工具的保留配置。 +- 本决策不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 +- 本决策不增加逐工具的保留配置。 - 不增加面向模型的超时/截断参数。 - 不把 `read` 输出迁移到 spill 文件。 - 不取代 `web-fetch-http.maxBodyChars` 等提供方/资源上限。 @@ -174,9 +174,9 @@ ctx.tools.register(defineTool({ 默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果 spill 而不是提前 spill,因此可以接受这一限制;由工具负责的提前 spill 仍属于后续工作。 -本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 +本地后端返回真实路径,使其保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 -本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使 spill 目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地 spill 路径,或改用检索提示指向受支持读取器的非文件 spill 后端。 +本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使 spill 目录位于会话 cwd 之外。这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地 spill 路径,或改用检索提示指向受支持读取器的非文件 spill 后端。 **快照缺口。** 目前没有 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` 场景。 @@ -184,7 +184,7 @@ ctx.tools.register(defineTool({ ## 考虑过的替代方案 -**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 +**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 **把 `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 b8bcc4246e..be7aedb4bd 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: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab +2026-07-10-single-file-executable-sdk-runtime-distribution.md: e5438fc01fdd634cd4d8535dfa37a1de356da04d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 218133f077bed7584b6a26fea5f939b64d4fd670 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 40433d99e5..e5438fc01f 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,12 +23,12 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h 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: the two packages sdk/server + examples/jsonrpc-demo +### The serving interface is a plugin: the two packages sdk/server + sdk/python-runtime The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.md) (`@deepseek-ai/dsh-sdk-python-runtime`): a private packaged entry — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the packaged entry (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. @@ -36,19 +36,19 @@ Config discovery has two channels and fails loudly when both are missing: the `D 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-jsonrpc-agent-pkg`, 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 `apps/cli/config/agent-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-sdk-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 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 or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. @@ -56,7 +56,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ### Naming lineage -`@deepseek-ai/dsh-sdk-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. +`@deepseek-ai/dsh-sdk-python-runtime` (the private carrier) → `dsh-sdk-python-runtime-closure` (the deploy manifest; no scope prefix, so it is not a dsh release package) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. 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 cff72ae10e..218133f077 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,12 +23,12 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包 +### 对外服务接口也是插件:sdk/server + sdk/python-runtime 两个包 确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: - [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.zh.md)(`@deepseek-ai/dsh-sdk-python-runtime`):私有打包入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归打包入口所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 @@ -36,19 +36,19 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 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-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-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-sdk-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` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 [`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 @@ -56,7 +56,7 @@ exe「必须显式配置」的硬语义不变;零配置体验由包装层恢 ### 命名血统 -`@deepseek-ai/dsh-sdk-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 +`@deepseek-ai/dsh-sdk-python-runtime`(私有载体)→ `dsh-sdk-python-runtime-closure`(部署 manifest;没有作用域前缀,因此不属于 dsh 发布包)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index c9fe4938a3..eb7a9adfab 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.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-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b93b333c6f25a779b1d307b6141bea9f5eb1e407 +2026-07-15-llm-model-catalog-and-acp-selection.md: fef23711a9214eae414809833bcd9ee9e26105ae +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 85e9fa35ac99b72a7df207fdb4971b57cf6dc525 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 3dddbe7e9f..fef23711a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) -> The catalog decision remains current. Per-session ACP model selection is superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). +> The catalog and scoped-selection decisions remain current. The temporary removal of ACP selection is superseded by [standard ACP v1 automation controls](../feature/2026-08-22-standard-acp-automation-controls.md), which exposes the catalog through standard session configuration without restoring UI projections. ## Problem @@ -24,17 +24,17 @@ ACP selection must also preserve the provider dimension. The same model id may a Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input. -`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. +`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash`, `deepseek-v4-pro` named `DeepSeek-V4-Pro`, and image-capable `deepseek-v4-flash-vision-exp` named `DeepSeek-V4-Flash-Vision-Exp`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. ### Per-session selection in the front end -A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it, never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. -The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. +The ACP automation transport consumes the advisory catalog through standard session configuration options. Its deployment config still supplies the initial provider/model target; each session owns an opaque provider/model choice and a dependent exact-model reasoning-effort choice. Adapter topology changes publish the complete option state. Catalog absence never invalidates the configured route: the current unlisted route is synthesized into the choices. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Ordinary consumers snapshot the selection once per step. ACP associates its admission snapshot with the identified message in the per-session module until inbox claim, then pins that selection for the complete admitted turn, so asynchronous image admission, prompt variables, and every request step remain aligned without changing the durable user source. A concurrent selection starts on the next ACP turn. Other call-config fields remain untouched. The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. @@ -53,10 +53,10 @@ The request header remains the durable source of truth. When a selection is actu - Any adapter can expose a dynamic model list without leaking provider-library types into the LLM Service Definition. - Catalog consumers must treat absence as “not advertised,” never “invalid request.” - pi-ai adapters expose their installed provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support. -- Human-facing catalog consumers own their selection interaction. ACP uses its fixed deployment target and does not widen the protocol with model discovery. +- Each catalog consumer owns its selection interaction. ACP uses standard session configuration options and emits no DSH-specific selector or UI metadata. - Request headers remain compatible with the provider-routed session shape; no new JSONL event or format version is required. - A catalog read can be asynchronous, and every caller receives detached values. ## Testing -Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP transport tests validate fixed provider/model forwarding independently of catalog discovery; the TUI suite covers selector interaction and header-based restoration. +Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP tests validate grouped discovery, invalid and concurrent changes, topology updates, header-based restoration, per-turn route pinning, and image-route consistency; human clients test their own selector presentation. diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index b93b333c6f..85e9fa35ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文 -> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 +> Catalog 和 scoped selection 决策仍然有效。ACP selection 的暂时移除已由[标准 ACP v1 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)取代;后者通过标准会话配置公开 catalog,但不会恢复 UI 投影。 ## 问题 @@ -24,17 +24,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。 -`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 +`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash`、名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`,以及名为 `DeepSeek-V4-Flash-Vision-Exp`、支持图片输入的 `deepseek-v4-flash-vision-exp`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 ### 前端内的会话级选择 -选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有,而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 -ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 +ACP 自动化传输层通过标准会话配置选项消费建议性 catalog。部署配置仍提供初始提供方/模型目标;每个会话拥有一个不透明的提供方/模型选择,以及一个依赖确切模型的 reasoning-effort 选择。Adapter 拓扑变化会公布完整选项状态。Catalog 中缺少条目不会使配置路由失效:当前未列出的路由会合成到选项中。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。普通 consumer 每个步骤快照一次选择。ACP 会在 per-session 模块中把准入快照与已识别消息关联到 inbox claim 时刻,再在完整已准入轮次中固定该选择,使异步图片准入、提示词变量和每个请求步骤保持一致,同时不改变持久用户 source。并发选择变更从下一个 ACP 轮次开始。其他调用配置字段保持不变。 请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 @@ -53,10 +53,10 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建 - 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到 LLM Service Definition。 - 目录消费方必须把缺失理解为「未展示」,而不是「请求无效」。 - pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留对任意模型的支持。 -- 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围。 +- 每个 catalog consumer 拥有自己的选择交互。ACP 使用标准会话配置选项,不发出 DSH 专用 selector 或 UI 元数据。 - 请求头与基于提供方路由的会话形态保持兼容;不需要新的 JSONL 事件或格式版本。 - 目录读取可以是异步的,且每个调用方都会收到值的独立副本。 ## 测试 -单元测试覆盖目录值副本与格式错误的元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复。 +单元测试覆盖 catalog 值副本与格式错误的元数据、pi-ai 和 DeepSeek catalog 投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 测试覆盖分组发现、无效和并发变更、拓扑更新、基于请求 header 的恢复、逐轮路由固定以及图片路由一致性;人工客户端测试自己的 selector 展示。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 1ed087f38c..38e07804b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 620803668e88f5a462ab2a75e6e916a85d433ed6 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 137cbce3e5dbc0be2736472f6e26d58112422697 +2026-07-19-gui-layering-and-rpc-protocol.md: 372bf4926011835999ebae9b1e2d1f5beb8eb663 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecce57c01c155c2a0b19b7729da13c39d1a520a6 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 620803668e..372bf49260 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -209,7 +209,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa ### The instance-level envelope observation aspect -All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). +All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). ### The subclass table (transport carriage) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 137cbce3e5..ecce57c01c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -207,7 +207,7 @@ export type ResponseValue = ### 实例级 envelope 观测切面 -四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费方;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费方订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费方,将来的诊断消费方接入时不动载体)。 +四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费方;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。没有任何已交付消费方订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费方,将来的诊断消费方接入时不动载体)。 ### 子类表(传输承载) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 9ee365f446..b80381f031 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908 +2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 +2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 8b4f940299..4448fd5c68 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -48,7 +48,7 @@ There is no component registration model besides slots — the former view and t **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). -## The data object layer (`packages/client/runtime/src/client/sessions/`) +## The data object layer (`packages/api/session-controller/src/client/`) Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 705b1337dd..e8a8121a1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -48,7 +48,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 **scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 -## 数据对象层(`packages/client/runtime/src/client/sessions/`) +## 数据对象层(`packages/api/session-controller/src/client/`) 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.i18n.yaml similarity index 58% rename from .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.i18n.yaml index 20ffb2b3e5..5b18b13430 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md -2026-08-11-plugin-settings-tabs.md: 96e4c48926c835bcc3e6b5bf2d59126a999fbb2b -2026-08-11-plugin-settings-tabs.zh.md: fa8f4626408316fd1afbfa1ab4b4cab14d69c538 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.md +2026-07-20-todo-event-ownership.md: f3f7f872b24d8f20b6b9acb57710fae388c8b9d8 +2026-07-20-todo-event-ownership.zh.md: a05622dc39163c4f5b30a93190d9d56bb02cf29a diff --git a/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.md b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.md new file mode 100644 index 0000000000..f3f7f872b2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.md @@ -0,0 +1,31 @@ +# Agent Note: todo event types belong to their producer + +Status: implemented + +English | [中文](2026-07-20-todo-event-ownership.zh.md) + +## Problem + +`SessionEventMap` is merge-extensible so each plugin can add durable records without making the core session package depend on every event producer. `todo/write` and its `TodoItem` payload are produced and interpreted by the todo domain, while core session only provides the generic append, replay, surface, and invariant extension mechanisms. Declaring todo-specific types or relationships in core would make the session spine own a plugin vocabulary it cannot produce or validate completely. + +## Decision + +`@deepseek-ai/dsh-tool-todo` declares `TodoItem` and merges `todo/write` into `@deepseek-ai/dsh-session/types` from its type-only outlet. The package root and `/client` entrypoint re-export `TodoItem`, so host and browser consumers share one declaration without loading the todo plugin. + +Consumers that inspect todo records use type-only imports plus explicit package dependencies and TypeScript project references. The emitted JavaScript has no todo import, and a composition does not need to mount the todo tool merely to search, transmit, or render a log that may contain `todo/write`. + +The todo invariant companion owns both the payload rules and the event's relationship to an open turn. Core session's merge-extensible switch falls through for `todo/write`, while the todo companion rejects malformed snapshots and snapshots outside an open turn before append. It validates existing and newly announced sessions in one pass and advances a committed per-session turn trace for later events. Todo-specific append, replay, projection, and enclosure tests live with the todo package. The model-facing behavior remains owned by the [`todo_write` feature decision](../feature/2026-06-29-todo-write-tool.md). + +## Verification + +Focused todo tool, invariant, projection, integration, and Loader-composition tests exercise the producer and its companion. Session-query extraction and client runtime/connection tests prove type-only consumers retain semantic todo handling. Workspace typecheck proves declaration merging through the explicit project graph; generated event, persistence, API, and module catalogs record the declaration site and dependency edges. + +## Alternatives considered + +- **Keep the payload type in core as shared UI vocabulary** — rejected because rendering reuse does not make core the producer or semantic owner of the durable event. +- **Narrow `todo/write` structurally in each consumer** — rejected because duplicate payload declarations can drift and bypass the merge-extensible event map. +- **Require every consumer to mount the todo plugin** — rejected because reading a durable record is a type and data dependency, not authorization to install a model-facing tool. + +## Consequences + +The core session package does not export `TodoItem` or enforce todo relationships. A package that names or narrows `todo/write` declares a type-only dependency on `dsh-tool-todo`; consumers that treat unknown merged events generically need no dependency. The todo package is the single source for the event payload, client type, runtime validation, and open-turn rule. diff --git a/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.zh.md new file mode 100644 index 0000000000..a05622dc39 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-todo-event-ownership.zh.md @@ -0,0 +1,31 @@ +# Agent Note: todo 事件类型归其生产方所有 + +Status: implemented + +[English](2026-07-20-todo-event-ownership.md) | 中文 + +## 问题 + +`SessionEventMap` 可通过声明合并扩展,使每个插件都能添加持久记录,而无需让核心会话包依赖所有事件生产方。`todo/write` 及其 `TodoItem` payload 由 todo 领域生产和解释;核心会话只提供通用的追加、回放、surface 与不变量扩展机制。在核心中声明 todo 专属类型或关系,会让会话主干拥有一个它既不生产、也无法完整校验的插件词汇。 + +## 决策 + +`@deepseek-ai/dsh-tool-todo` 在其仅类型出口中声明 `TodoItem`,并通过 `@deepseek-ai/dsh-session/types` 的声明合并加入 `todo/write`。包根入口和 `/client` 入口重新导出 `TodoItem`,使 host 与浏览器消费方共享同一处声明,而无需加载 todo 插件。 + +检查 todo 记录的消费方使用仅类型导入,并声明显式包依赖与 TypeScript 项目引用。产出的 JavaScript 不含 todo 导入;组合仅为了搜索、传输或渲染可能含有 `todo/write` 的日志时,无需挂载 todo 工具。 + +todo 不变量配套插件同时拥有 payload 规则和事件必须位于开放轮次内的关系。核心会话的可合并扩展 switch 对 `todo/write` 走默认分支;todo 配套插件会在追加前拒绝格式错误或位于开放轮次之外的快照。它会单次校验现有会话与新发布的会话,并为后续事件推进逐会话的已提交轮次追踪状态。todo 专属的追加、回放、投影和轮次封闭测试与 todo 包放在一起。面向模型的行为仍由 [`todo_write` 功能决策](../feature/2026-06-29-todo-write-tool.zh.md)负责。 + +## 验证 + +聚焦的 todo 工具、不变量、投影、集成和 Loader 组合测试覆盖生产方及其配套插件。session-query 提取与客户端 runtime/connection 测试证明仅类型消费方仍能保留 todo 的语义处理。全工作区类型检查证明声明合并通过显式项目图生效;重新生成的事件、持久化、API 与模块目录记录声明位置和依赖边。 + +## 曾考虑的替代方案 + +- **把 payload 类型留在核心中作为共享 UI 词汇**——拒绝:渲染复用并不会让核心成为持久事件的生产方或语义所有方。 +- **让每个消费方各自按结构收窄 `todo/write`**——拒绝:重复的 payload 声明会漂移,并绕过可合并扩展的事件表。 +- **要求每个消费方都挂载 todo 插件**——拒绝:读取持久记录是类型和数据依赖,并不构成安装面向模型工具的授权。 + +## 后果 + +核心会话包不导出 `TodoItem`,也不强制 todo 关系。命名或收窄 `todo/write` 的包声明对 `dsh-tool-todo` 的仅类型依赖;只把未知合并事件作通用处理的消费方无需依赖它。todo 包是事件 payload、客户端类型、运行时校验和开放轮次规则的唯一来源。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 0221b115ce..c09133e0c0 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.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-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 173af01ff6cd5dc74f2f0916fd49ad278636d1bb -2026-07-23-toolview-dissolution.zh.md: 23f6f5dd037531892fde93ea170f958b535d89db +2026-07-23-toolview-dissolution.md: 3e38d2faf45d851b3f8f9a10459588570190b3fb +2026-07-23-toolview-dissolution.zh.md: 79c1f7d7630389462bf77d34df614bfd32798d50 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 173af01ff6..3e38d2faf4 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -18,7 +18,7 @@ This decision originally placed `'conversation.chat.toolview'` under the chat en ## Accepted semantic changes -Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance was initially per-view registration; the follow-up note records why root/subcall composition later justified one Tool-wide presentation owner. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. +Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance was initially per-view registration; the follow-up note records why root/subcall composition later justified one Tool-wide presentation owner. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index 23f6f5dd03..79c1f7d763 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 接受的语义变化 -四项行为增量是刻意接受而非疏漏。跨视图出场最初采用逐视图注册;后续 Note 记录了为何 root/subcall 编排后来证明由一个 Tool 级展示所有者统一负责是合理的。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 +四项行为增量是刻意接受而非疏漏。跨视图出场最初采用逐视图注册;后续 Note 记录了为何 root/subcall 编排后来证明由一个 Tool 级展示所有者统一负责是合理的。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——没有已交付的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 32caddfbc6..64e6bbc289 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 81ae4bdfa4f9666efc9eccd05bc4fe95d24b3dba -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: dd5c09696525b0391b1c74693753ff2f6d372eba +2026-07-25-web-client-session-scope-and-provide-channel.md: 6f159dc16e0e4063f9077c31829a10caca98eae0 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 2cb082dce75f1845e52a52289a8e3eaa1a000931 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 81ae4bdfa4..6f159dc16e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -132,5 +132,5 @@ Slot scope is the closed set `root | session-maybe | session`: - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident card routes activation to the Workspace picker during that interval ([decision](../feature/2026-08-07-workspace-picker-composer-entry.md)). +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index dd5c096965..2cb082dce7 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -132,5 +132,5 @@ slot scope 是闭集 `root | session-maybe | session`: - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,常驻卡片会把激活操作转到 Workspace picker([决策](../feature/2026-08-07-workspace-picker-composer-entry.zh.md))。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.i18n.yaml index 55f1a4c04c..4c1d7988e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.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-26-job-registry-seam.md -2026-07-26-job-registry-seam.md: b4a8a66ef63f1a4955e2497cad2d0d0b1ef138ec -2026-07-26-job-registry-seam.zh.md: 1e990542e946854d85dd7d1accf3e5bba8ca2ccb +2026-07-26-job-registry-seam.md: fc344c9a9b24c13871475993dc52d7fdb92ed3be +2026-07-26-job-registry-seam.zh.md: 692937cfbae599b4dcaacdc31220a15f17a912aa diff --git a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md index b4a8a66ef6..fc344c9a9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md @@ -22,7 +22,7 @@ The seam keeps the in-process contract semantics unchanged: `JobStart.run()` sti ## Alternatives considered -**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the nine service methods and their semantics have been stable across every producer integration since introduction, they are exactly the API `dsh-tool-jobs` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and today they would also churn every Consumer's provider dependency. +**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the nine service methods and their semantics have been stable across every producer integration since introduction, they are exactly the API `dsh-tool-jobs` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and they would also churn every Consumer's provider dependency. **Service-Definition-only extraction inside one package (export an abstract class beside the concrete one).** Rejected because it separates nothing operationally: Consumers still depend on the package that carries the provider and its dependencies, and a replacement backend still cannot ship without the local one in its graph. The package boundary is the unit of independent evolution here. diff --git a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md index 1e990542e9..692937cfba 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md @@ -22,7 +22,7 @@ Status: implemented ## 曾考虑的替代方案 -**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二个 Service Provider 出现前抽取 Service Definition,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:九个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-jobs` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更约定)不因这次拆分而改变:无论拆分与否,这类变更都会落在 Service Definition 包里;而若维持现状,它们今天还会连带搅动每个 Consumer 的提供方依赖。 +**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二个 Service Provider 出现前抽取 Service Definition,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:九个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-jobs` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更约定)不因这次拆分而改变:无论拆分与否,这类变更都会落在 Service Definition 包里;而若维持现状,它们还会连带搅动每个 Consumer 的提供方依赖。 **在单个包内仅抽取 Service Definition(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:Consumer 依然依赖携带 Service Provider 及其依赖项的那个包,而替换后端若不把本地 Service Provider 纳入自身依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index bce9b41482..c206e01dba 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: f4d14201d052299bb6063553e962a5d7c74fdb4b -2026-07-28-api-browser-trust-boundary.zh.md: 8c8e414e5f76b2ef2090ddc99916defa196da352 +2026-07-28-api-browser-trust-boundary.md: 92b76c109aa9b55bc72bb76f8b208ea2d814d8ce +2026-07-28-api-browser-trust-boundary.zh.md: 653ff32f2e62bf0e2982517f82649ff2d06e40d4 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index f4d14201d0..92b76c109a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -21,7 +21,7 @@ Two boundaries stay deliberately out of scope: reachability is the webserver bin - **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for. - **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler. -- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design. +- **Authentication tokens.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes without pre-deciding the auth design. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 8c8e414e5f..653ff32f2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -21,7 +21,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho - **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。 - **CORS 头与省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。 -- **现在就上认证令牌。** 在本变更中否决:令牌的签发、存储、轮换是真实的产品面;栅栏今天就能封死浏览器混淆代理人漏洞,无需预先决定认证设计。 +- **认证令牌。** 在本变更中否决:令牌的签发、存储、轮换是真实的产品面;栅栏能够封死浏览器混淆代理人漏洞,无需预先决定认证设计。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 994bd33a39..a72957b18a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 2e663e9bc90841dd843bf8a8ff5676e4ca3b91b3 -2026-07-28-directory-picker-capability-seam.zh.md: 46d62aeaadd73d00de71be8ecd7c478b978f918d +2026-07-28-directory-picker-capability-seam.md: 423fec3ad517e645f1cdee3513bad312a56989a8 +2026-07-28-directory-picker-capability-seam.zh.md: f652157fc152dee44a50ab8b55cc6120d92a1a26 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 2e663e9bc9..423fec3ad5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — assistive technology reads them as separate widgets. - **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (`scanned`), because the Host resolves what it is given: `..` segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 46d62aeaad..f652157fc1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、沙箱可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的提供方)是整套 HTTP 应用(契合度不过),盘符工具(原生扩展 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配器。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,「显示隐藏」开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测的收益抵得上其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——辅助技术会把二者读成是彼此独立的控件。 - **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时**整条栏**以编辑器自身的那只框亮起——轮廓与内边距在两种模式下都由栏承载,于是悬停预览的正是点击后出现的那只输入框,区域与输入框互换时也没有任何尺寸变化。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。「只要任意一栏碰巧列出了该目录就跳过扫描」是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作「我在哪儿、我从哪儿来」。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。另有两条规则保证一次按键只让视图移动一次:这段行走会**等齐两程**,而不套用提交导航的等待上限(推测性扫描没有任何东西在等它,先落单栏再升级恰恰就是它要避免的那次闪动,而且一旦末段按键中止了缓慢的父层级这一程,双栏视图就会永久丢失);末段也只过滤**最后一栏**——去收窄一个草稿已经走开的分栏,会让视图先因收窄动一次、再因它自己的落地动一次。此外,层级会持续应答产生它的那段目录文本(`scanned`),因为宿主会解析它收到的东西:`..` 段与 Windows 的正斜杠都会抵达一个自身路径拼写不同的层级;没有这份记忆,这类草稿会每敲一键就重扫一次,而且永远过滤不了。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。三个时序常量——200ms 父层级上限、300ms 静默窗口,以及编辑器的 250ms 草稿停顿——都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态,而且要先付停顿再付 RPC 分栏才跟上——待远程消费方落地时,三者一并重新审视。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 4ec48834d9..a46174dd21 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: 425cf46ca802e8d3ac1d49a1955c5e7ea208bd20 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 87b323c82f28135396d1d16991e2365c691fcc6a +2026-07-29-dsh-source-launch-tsx-esm.md: 23cf4023e91d148b2e129faa7395fdccdd3e0a2b +2026-07-29-dsh-source-launch-tsx-esm.zh.md: e2dede0036dd1128718bdf41a11f5949555b69a5 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 425cf46ca8..23cf4023e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -26,7 +26,7 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` **Make the source graph erasable-only so Node 26 strip mode accepts it.** Rejected: parameter properties and value namespaces pervade vendored Cordis/cosmokit/loader/schemastery; rewriting them is unbounded churn re-applied on every vendor sync. -**A repo-owned in-thread loader (`module.registerHooks()` + esbuild or `@swc/core` transform).** Rejected for now: prototypes measured ~0.45s (esbuild path untested end-to-end; SWC breaks on `vendor/hmr`'s decorator + namespace merge in both decorator modes), but it means owning transform correctness and a resolve hook that tsx already provides. Revisit only if the ~0.3s gap becomes a real cost; the profiling evidence lives in the PR discussion. +**A repo-owned in-thread loader (`module.registerHooks()` plus an esbuild or `@swc/core` transform).** Rejected: prototypes measured about 0.45s, while the esbuild path lacked end-to-end validation and SWC failed on `vendor/hmr`'s decorator plus namespace merge in both decorator modes. This option also makes the repository own transform correctness and a resolve hook that tsx already provides. Revisit only if the measured 0.3s gap becomes a material cost. **Run built `lib/` for Node 26 and keep native for 24.** Rejected: loses the zero-build development loop on the newest Node line and mixes source and artifact planes. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 87b323c82f..e2dede0036 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -26,7 +26,7 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` **把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。 -**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但这意味着要自行负责转换正确性,以及实现 tsx 已经提供的解析钩子。仅当约 0.3s 的差距成为真实成本时再重新考虑;性能分析证据在 PR 讨论中。 +**仓库自有的同线程 loader(`module.registerHooks()` 加 esbuild 或 `@swc/core` 转换)。**不予采纳:原型实测约 0.45s,而 esbuild 路径缺少端到端验证,SWC 在两种装饰器模式下都会因 `vendor/hmr` 的装饰器与 namespace 合并失败。该方案还会让仓库负责转换正确性和 tsx 已经提供的解析钩子。仅当实测约 0.3s 的差距成为实质成本时再重新考虑。 **Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index c35962e7d3..66dff1b8ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab -2026-07-30-client-locale-full-rollout.zh.md: 427c9e5ef9c544a49e70b6ba8450511072f53a6e +2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910 +2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 6701aefa45..aeb4deae28 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -1,4 +1,4 @@ -# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary +# Agent Note: Full client copy rollout onto the typed locale seat Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md) ## Problem -After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization. +After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime. ## Decision @@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). -**The non-translation boundary (deliberate decisions, not debt):** +**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. -- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim. -- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages. -- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately). -- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists). - -**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. +**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter. **Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). @@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision. - **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently. -- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text. +- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not. - **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock. - **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic. @@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. -- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. +- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language. - Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 427c9e5ef9..a642b6062c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -1,4 +1,4 @@ -# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界 +# Agent Note: client 文案全量接入 typed locale 席位 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## Problem -typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译。 +typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。 ## Decision @@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 -**不翻译边界(刻意决定,不是欠账):** +**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 -- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。 -- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。 -- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。 -- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。 - -**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。 +**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。 **测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。 @@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。 - **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。 -- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。 +- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会。 - **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。 - **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。 @@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 -- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 +- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。 - e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index b9f14a45d8..ac8f90ed76 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.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-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: aab0d7a80c2732b33637ec721a9ab7c1b50fce3f -2026-07-30-config-plane-boundaries.zh.md: 7e01aad93947266362cec31fdeab18524ea14cd9 +2026-07-30-config-plane-boundaries.md: 7b234ec64669cc1e6f0d5a67437005747edcea98 +2026-07-30-config-plane-boundaries.zh.md: f543c511d9374a50955331911b2eb2d62dab0675 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index aab0d7a80c..7b234ec646 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -22,7 +22,7 @@ Three smaller defects sat beside them. `llm/adapters-updated` documented contain **Reading configuration is as privileged as writing it.** `settings.describe` and `credentials.describe` join the loopback-only set, so the whole configuration plane stays same-origin until real authentication exists. The model catalog (`llm.providers`, `llm.models`) deliberately does not: it carries provider ids, display names, and model lists — no endpoints, no key state — and a LAN client's model picker needs it. The boundary is asserted over a real HTTP server rather than a hand-assembled request, because the `Host` header a browser actually sends is what decides it. -**The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from today's plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry. +**The plane serves exactly the namespaces a registered model provider addresses.** `ctx.llm.listConfigurableProviders()` is the allow-list, so the product boundary is enforced rather than inferred from the installed plugin set, and a future namespace becomes web-configurable only by joining that directory. An unregistered namespace and an unexposed one answer identically (`settings-not-exposed`), so probing cannot enumerate the registry. **A caller with a partial view names the field it means.** `SettingsProvider.mutate(ns, ops)` applies `set`/`unset` path ops to the section as it stands at the front of the write queue. The client builds ops by diffing its opening snapshot against its draft, so it mentions only fields it can see: a secret absent from both sides produces no op and survives by construction, not by care. `replace` remains the deliberate wholesale reset. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index 7e01aad939..f543c511d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -22,7 +22,7 @@ Status: implemented **读取配置与写入配置同样属于特权操作。**`settings.describe` 与 `credentials.describe` 加入仅限回环的集合,因此在真正的认证层出现之前,整个配置面都保持同源。模型目录(`llm.providers`、`llm.models`)刻意不在其中:它携带的是提供方 id、显示名与模型列表——没有端点、没有密钥状态——而 LAN 客户端的模型选择器正需要它。这条边界由一台真实 HTTP 服务器来断言,而不是手工拼装的请求,因为真正决定它的,是浏览器实际发出的那个 `Host` 头。 -**这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从今天的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。 +**这个面恰好服务于已注册模型提供方所指向的那些 namespace。**`ctx.llm.listConfigurableProviders()` 就是允许列表,于是产品边界是被执行的,而不是从已安装的插件集合里推断出来的;将来的 namespace 只有加入该目录才会变得可在 Web 上配置。未注册的 namespace 与未暴露的 namespace 得到完全相同的答复(`settings-not-exposed`),因此探测无法枚举注册表。 **持有局部视图的调用方,点名它真正要改的字段。**`SettingsProvider.mutate(ns, ops)` 会把 `set`/`unset` 路径 op 施加在写入排到队首那一刻的分节上。客户端通过对比自己打开时的快照与草稿来构造 op,因此它只提及自己看得见的字段:两侧都没有的机密不会产生任何 op,它的留存是构造使然,而非小心使然。`replace` 仍是那个刻意的整体重置。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml index 82e5a70c63..b096299ad0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.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-30-credential-boundaries-and-atomic-registration.md -2026-07-30-credential-boundaries-and-atomic-registration.md: 95c16ef2bf3d82d7b8b53e3975ece3f0c63402f3 -2026-07-30-credential-boundaries-and-atomic-registration.zh.md: d09f24c3bd93934432f543e77bb8d92595b458ad +2026-07-30-credential-boundaries-and-atomic-registration.md: 32b49fbc957b251606627c471e3211909a35cf68 +2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 7067ee1d1f610aa65ffed456326b422f3137d7e8 diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md index 95c16ef2bf..32b49fbc95 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md @@ -14,7 +14,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede ## Decision -**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so today it is the old path that is loaded — as the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. +**The credential document belongs to the credential provider alone.** No surface loads it into `process.env`. It was `$DSH_HOME/.env` here; the [credentials document split](2026-08-04-credentials-yaml-and-user-environment-layer.md) later moved it to `$DSH_HOME/.credentials.yaml`, so `$DSH_HOME/.env` is the user's ordinary environment layer, holding no provider-managed secret. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`. **The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one. diff --git a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md index d09f24c3bd..7067ee1d1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 决策 -**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何提供方管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 +**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。当时该文档是 `$DSH_HOME/.env`;[凭据文档拆分](2026-08-04-credentials-yaml-and-user-environment-layer.zh.md)后来把它移到 `$DSH_HOME/.credentials.yaml`,因此 `$DSH_HOME/.env` 是用户的普通环境层,其中不含任何提供方管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。 **存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认配置不提供任何约束。harness 真正守住的边界更窄,文档也严格按这一范围表述:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml index 6a93aa5014..41be5b3340 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.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-30-followup-enqueue-and-owned-runs.md -2026-07-30-followup-enqueue-and-owned-runs.md: 9978b3a8ab8678fe98e000476505cee9dcaa1bc6 -2026-07-30-followup-enqueue-and-owned-runs.zh.md: 3b33a8dcf550edb036f07f40bab86ca3d0171bd7 +2026-07-30-followup-enqueue-and-owned-runs.md: e056d23d72121053c2aeaec44ff307c514c1ae49 +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 4200c6b480298a2e487667cf953f6eea7d553736 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md index 9978b3a8ab..e056d23d72 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -18,7 +18,7 @@ The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their final response is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The Python SDK also reports the last root turn's reason kind as the run-level [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md), without attributing it to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. -ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal. +ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session and owns the interval from admission through whole-Agent idle and ordered update delivery. It correlates the turn that admits the identified ACP message without claiming that every activity in the interval was caused only by that message. A correlated token-limit ending maps to standard `max_tokens`; a correlated model error rejects at the same quiescence boundary; a turnless slot settles as `cancelled` alongside explicit ACP cancellation or disposal. Other normal quiescence reports `end_turn`. Goal continuation retains `MessageId` only to recognize its durable queued and admitted goal message. It advances from durable goal state at whole-agent idle, without mapping the message to a turn result. @@ -39,4 +39,4 @@ Goal continuation retains `MessageId` only to recognize its durable queued and a ## Consequences -An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from SDK and ACP results; callers may inspect run-level or durable event facts without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. +An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from the low-level DSH SDK result. ACP projects the correlated turn into its required standard error or `max_tokens` stop reason at interval quiescence, without adding a DSH-specific result or claiming exclusive causality. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-follow-up result. diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md index 3b33a8dcf5..4200c6b480 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -18,7 +18,7 @@ Status: implemented 只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其最终响应是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。Python SDK 还把根会话最后一个轮次的结束原因 kind 作为运行级 [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.zh.md) 返回,但不会将其归因于已提交的提示词。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 -ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层对每个 ACP 会话中的提示词进行串行处理,确保一次只有一个提示词正在处理,等待整个 agent 进入 idle,其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误拒绝提示词(错误按其所属轮次归因),而无轮次的 slot(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或 dispose(资源释放)并列。 +ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层对每个 ACP 会话中的提示词进行串行处理,并拥有从准入到整个 Agent idle 和有序更新交付的区间。它会关联准入该已识别 ACP 消息的轮次,但不会声称区间内所有活动都只由该消息引起。关联的 token 上限结尾映射为标准 `max_tokens`;关联模型错误在同一个完全停稳边界拒绝;无轮次 slot 与显式 ACP 取消或 dispose 一样以 `cancelled` 结算。其他正常完全停稳报告 `end_turn`。 Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal 消息。它在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。 @@ -39,4 +39,4 @@ Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal ## 后果 -自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。SDK 和 ACP 结果仍不包含提示词级模型错误和 token 上限分类;调用方可以检查运行级或持久事件事实,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 +自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。底层 DSH SDK 结果仍不包含提示词级模型错误和 token 上限分类。ACP 会在区间完全停稳时把关联轮次投影成其必需的标准 error 或 `max_tokens` stop reason,但不增加 DSH 专用结果,也不声称排他因果关系。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的逐 follow-up 结果。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index dc41a56889..ca0cd569c9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: ac989cb100190e9a41ebf04b5b2d80125d49e0cb -2026-07-30-web-config-plane.zh.md: 538a90ae107e69c61c039d94efe47b258f313d55 +2026-07-30-web-config-plane.md: c8397d03cd7b8eb4ea127cdc26124f5e721e822d +2026-07-30-web-config-plane.zh.md: b0724c3a8cb160cbac5fc88fe07d35e79accfc49 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index ac989cb100..c8397d03cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -27,9 +27,9 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Alternatives considered - **Serving JSON Schema over the wire** — schemastery's `toJSON()` envelope round-trips `role()`/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on. -- **A generic schema-driven form renderer** — implemented first, then replaced: field truth without visual hierarchy produced an ugly, unusable card, and making it good meant building a hint vocabulary (primary/advanced grouping, per-field descriptions, array item cards) rivaling the hand-written editor in cost while still fitting no mockup exactly. Two schemas exist today (the deepseek `Config` and the shared pi-ai profile), so hand-writing is two thin namespace-keyed layouts; the drift risk is bounded by save-time schema validation and by unknown fields staying untouched in the document. +- **A generic schema-driven form renderer** — implemented first, then replaced: field truth without visual hierarchy produced an ugly, unusable card, and making it good meant building a hint vocabulary (primary/advanced grouping, per-field descriptions, array item cards) rivaling the hand-written editor in cost while still fitting no mockup exactly. The two relevant schemas are the DeepSeek `Config` and the shared pi-ai profile, so hand-writing is two thin namespace-keyed layouts; the drift risk is bounded by save-time schema validation and by unknown fields staying untouched in the document. - **Masking secrets per-field with sentinel backfill on `replace`** — the request-level seam decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol. -- **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. +- **Storing the typed key as a literal `apiKey` setting** — the single API key input requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as in the request-level seam note: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. - **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-file.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 538a90ae10..b0724c3a8c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -27,9 +27,9 @@ Status: implemented ## 曾考虑的替代方案 - **在 wire 上改发 JSON Schema**——schemastery 的 `toJSON()` 信封能往返保留 `role()`/meta,并还原成客户端为草稿校验本就自带的那个校验器;转换成 JSON Schema 丢掉的恰恰是凭据控件与 secret 脱敏所依赖的角色注解。 -- **通用的 schema 驱动表单渲染器**——先实现、后被替换:如实呈现字段却缺失视觉层级,产出的卡片丑陋且不可用;要把它做好,就意味着构建一套提示词汇(主要/进阶分组、逐字段描述、数组项卡片),成本堪比手写编辑器,却仍无法与任何设计稿完全吻合。今天存在两份 schema(deepseek 的 `Config` 与共享的 pi-ai profile),手写因此就是两套以 namespace 为键的薄布局;漂移风险由保存时的 schema 校验以及未知字段在文档中的原样保留共同约束。 +- **通用的 schema 驱动表单渲染器**——先实现、后被替换:如实呈现字段却缺失视觉层级,产出的卡片丑陋且不可用;要把它做好,就意味着构建一套提示词汇(主要/进阶分组、逐字段描述、数组项卡片),成本堪比手写编辑器,却仍无法与任何设计稿完全吻合。相关的两份 schema 是 DeepSeek 的 `Config` 与共享的 pi-ai profile,手写因此就是两套以 namespace 为键的薄布局;漂移风险由保存时的 schema 校验以及未知字段在文档中的原样保留共同约束。 - **逐字段脱敏机密并在 `replace` 时回填哨兵值**——请求级 seam 的决策(机密是引用)已经为产品默认形态删掉了「存储字面量」这种情况;结构化脱敏加上只写的凭据通道足以处理残余情形,无需让每个写入方都学会一套哨兵协议。 -- **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 +- **把键入的密钥存成字面 `apiKey` 设置**——单个 API 密钥输入框的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与请求级 seam note 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧,每个只需增加一种形状,就能让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 - **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-file.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 57aab42257..312c609705 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 5f9600a3f658df907d68ae695d42154009947fbd -2026-07-31-code-runtime-python-fd3-protocol.zh.md: ed6b7a70ab459b8b065805ee599528a766d2873a +2026-07-31-code-runtime-python-fd3-protocol.md: 5572fe58cb1dd8832ff9405670afc7f80a20362c +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 6254e94a7b48b38edfbe23a6ea0b994d04ac21f4 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 5f9600a3f6..5572fe58cb 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -6,21 +6,21 @@ English | [中文](2026-07-31-code-runtime-python-fd3-protocol.zh.md) ## Problem -The CPython code-runtime backend (`@deepseek-ai/dsh-code-runtime-python`, arriving across a PR stack) runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. That channel needs a wire protocol both sides agree on, and the host cannot trust it: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify`/`json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded. +`@deepseek-ai/dsh-code-runtime-python` owns the wire protocol intended for a CPython code-runtime provider. Such a provider runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. The host cannot trust that channel: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input that the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify` and `json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded. -This layer of the stack delivers only that protocol, so the large `PythonCodeRuntime` implementation and its real-subprocess integration suite land on a reviewed wire contract instead of arriving fused with it. The parent stack splits [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436) — a 9000-line single PR — into reviewable layers; this is the protocol layer, based on the [seam extension](2026-07-31-code-runtime-portable-identifier-seam.md). +The package ships the protocol independently from a runtime implementation. It exports no `PythonCodeRuntime`, subprocess path, or Python-side JSON codec; those remain work for a future provider. The protocol builds on the [portable identifier seam](2026-07-31-code-runtime-portable-identifier-seam.md). ## Decision `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the enqueued children; strings and keys are metered by a non-allocating escaped-size scan (`jsonStringBytesUpTo`), so the escaped copy is never materialized. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the incremental work it would otherwise add — the enqueued children; strings and keys are metered by a non-allocating escaped-size scan (`jsonStringBytesUpTo`), so the escaped copy is never materialized. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so a consuming runtime must cap fd-3 bytes before parsing. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. -The package skeleton (`package.json`, `tsconfig.json`, `tsdown.config.ts`, `src/index.ts`, `src/invariant.ts`, README triplet) ships here rather than in a later stack layer: `check-workspace-constraints` reads every `packages//` package.json unconditionally, and the coverage and invariant-topology gates require the package to exist and build the moment its directory does. The later backend-core PR extends `src/index.ts` with `PythonCodeRuntime` and grows `package.json`'s dependencies; because it bases on this branch, those are edits, not conflicts. +The package remains independently buildable with protocol-only exports. `check-workspace-constraints` reads every `packages///package.json` unconditionally, while the coverage and invariant-topology checks exercise the package as soon as its directory exists. ## Wire contract @@ -28,16 +28,16 @@ Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free f ## Mirror alignment -Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. To keep it aligned, `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`: `PROTOCOL_FD` and `log_truncation_marker` (the two surfaces both sides execute), and each `TypedDict`'s required/optional wire field set — so a renamed or dropped field, or one side making a field optional the other requires (exactly the round-12 drift), fails the test. Field *types* are not compared across the language boundary; that residue stays with review. +`py/protocol.py` and `src/protocol.ts` agree that `LogMessage` carries `truncated`, `DoneMessage.error` carries `kind`, and `Namespace` may carry `errorClass`. `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts `PROTOCOL_FD`, `log_truncation_marker`, and each `TypedDict`'s required and optional wire field sets against `src/protocol.ts`. A renamed or dropped field, or a required/optional mismatch, fails the test. Field *types* are not compared across the language boundary; review and a future provider's real-subprocess suite own that gap. ## Alternatives considered -**Move the Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) into `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates HOSTILE input and is self-contained. The Python codec produces output on the TRUSTED side and is coupled to bootstrap-internal helpers (`_Emit`, `_dump_scalar`/`_dump_string`/`_dump_float`, `LogBuffer`'s cost accounting, `_check_done_value`, `_lossless_json_violation`); lifting only the two entry points would drag that web into `protocol.py` or create a `bootstrap.py` ↔ `protocol.py` import cycle. The real cross-side parallel is "host validates inbound (`protocol.ts`) ↔ child trusts host and emits (`bootstrap.py`)", and that symmetry is preserved: `protocol.py` stays the pure wire-vocabulary mirror it is on the TS side. The Python codec stays in `bootstrap.py`, delivered by the backend-core PR. +**Require a future Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) to live in `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates hostile input and is self-contained. A child-side codec would produce trusted output and belong with bootstrap-owned emission and cost accounting; forcing only its entry points into `protocol.py` would couple the vocabulary mirror to runtime internals or create an import cycle. `protocol.py` remains a pure wire-vocabulary mirror. No Python codec ships in this package. -**Defer the package skeleton to the backend-core PR that "owns" package.json.** Rejected: the workspace-constraint, coverage, and invariant-topology gates fail the instant the `code-runtime-python` directory exists without a buildable package. A stacked split cannot create source files in a package that does not yet compile. +**Keep the protocol files outside a buildable package until a runtime ships.** Rejected: the workspace-constraint, coverage, and invariant-topology checks require every directory under `packages//` to be a buildable package, and the protocol has independent tests and a public wire vocabulary. ## Consequences -Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract. +Bought: the fd-3 protocol and its hostile-input codec form a self-contained, fully unit-covered layer, with an executing guard against TypeScript/Python field-set drift. A future runtime can consume a reviewed wire contract. -Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The mirror e2e compares field NAMES and required/optional-ness across the two sides but not field TYPES — comparing type declarations across TypeScript and Python has no mechanical equivalent, so that residue stays with review plus the backend's real-subprocess suite. +Cost: the package name denotes a Python runtime family while `src/index.ts` exports only the protocol vocabulary. The mirror e2e compares field names and required/optional status across the two sides but not field types; comparing type declarations across TypeScript and Python has no mechanical equivalent, so review and the future runtime's real-subprocess suite retain that responsibility. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index ed6b7a70ab..6254e94a7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -6,21 +6,21 @@ Status: implemented ## Problem -CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 +`@deepseek-ai/dsh-code-runtime-python` 负责供 CPython code-runtime 提供方使用的 wire protocol。这样的提供方会在全新的 `python3 -I` 子进程中运行每个模型程序,并通过子进程 fd 3 桥接 binding 调用与完成值。Host 不能信任这条通道:模型代码可以完全访问 fd 3 并伪造任意帧,因此 host 必须把每个入站帧视为敌意输入,先校验并重建后才能读取。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify` 和 `json.dumps` 都有递归深度限制。 -本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.zh.md)。 +该包独立交付协议,不包含 runtime 实现。它不导出 `PythonCodeRuntime`、子进程路径或 Python 侧 JSON codec;这些属于未来提供方。协议建立在[可移植标识符 seam](2026-07-31-code-runtime-portable-identifier-seam.zh.md)之上。 ## Decision `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——即入栈子节点;字符串与 key 由非分配的转义尺寸扫描(`jsonStringBytesUpTo`)计量,从不物化转义副本。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在新增入栈子节点之前就拒绝超预算 payload;字符串与 key 由非分配的转义尺寸扫描(`jsonStringBytesUpTo`)计量,从不物化转义副本。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已经过 `JSON.parse`,因此消费 runtime 必须在解析前限制 fd-3 字节数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 -包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 +该包只导出协议,同时保持独立可构建。`check-workspace-constraints` 会无条件读取每个 `packages///package.json`,coverage 与 invariant-topology 检查则会在包目录存在时立即覆盖该包。 ## Wire contract @@ -28,16 +28,16 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 ## Mirror alignment -#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。为持续保持对齐,`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言:`PROTOCOL_FD` 与 `log_truncation_marker`(两侧都会执行的面),以及每个 `TypedDict` 的必填/可选 wire 字段集——于是字段被重命名或删除、或一侧把另一侧要求的字段改成可选(正是 round-12 那类漂移),测试即失败。字段的*类型*不跨语言边界比较,那部分残留留给 review。 +`py/protocol.py` 与 `src/protocol.ts` 一致规定:`LogMessage` 携带 `truncated`,`DoneMessage.error` 携带 `kind`,`Namespace` 可以携带 `errorClass`。`tests/protocol-mirror.e2e.ts` 启动真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD`、`log_truncation_marker` 以及每个 `TypedDict` 的必填和可选 wire 字段集。字段改名、删除或必填/可选性不一致都会使测试失败。字段*类型*不跨语言边界比较;这项缺口由评审和未来提供方的真实子进程套件负责。 ## Alternatives considered -**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 “host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)”,这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 +**要求未来的 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)放进 `py/protocol.py`,以便与 `protocol.ts` 跨侧对称。**拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 中的 host 侧 codec 校验敌意输入且自包含。Child 侧 codec 会产出受信任输出,应与 bootstrap 拥有的发出逻辑和成本核算放在一起;只把入口强塞进 `protocol.py` 会让 vocabulary 镜像耦合 runtime 内部实现,或制造 import 环。`protocol.py` 保持纯 wire-vocabulary 镜像。本包尚未交付 Python codec。 -**把包骨架推迟到“拥有” package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 +**在 runtime 交付前把协议文件放在不可构建的包外。**拒绝:workspace-constraint、coverage 与 invariant-topology 检查要求 `packages//` 下的每个目录都是可构建包,而协议本身拥有独立测试与公开 wire vocabulary。 ## Consequences -收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 +收获:fd-3 协议及其敌意输入 codec 构成自包含、unit 全覆盖的一层,并由执行中的 guard 防止 TypeScript/Python 字段集漂移。未来 runtime 可以直接消费经过评审的 wire contract。 -代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。mirror e2e 比较两侧的字段名与必填/可选性,但不比较字段类型——跨 TypeScript 与 Python 比较类型声明无机械等价物,那部分残留留给 review 加后端真子进程套件。 +代价:包名表示 Python runtime 家族,而 `src/index.ts` 只导出协议 vocabulary。mirror e2e 会比较两侧字段名与必填/可选状态,但不比较字段类型;跨 TypeScript 与 Python 比较类型声明没有机械等价物,因此评审与未来 runtime 的真实子进程套件继续负责这项检查。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 918857d1f5..53bfd94dd7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: 645eaf94dc734c674d526d26a33731faded7071c -2026-08-02-typert-remote-method-calls.zh.md: 4fbc4cc64d8c266f02c27f339d7324251e091c9f +2026-08-02-typert-remote-method-calls.md: b95e3f0dec56287cbec2586921477284d0489a40 +2026-08-02-typert-remote-method-calls.zh.md: 50f04fd44a06ae3914998f0337fef09c75fe707c diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 645eaf94dc..b95e3f0dec 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -154,10 +154,10 @@ Descriptors exist only in the local registry on each side. The wire carries only ## Typert runtime registry ```text -ctx.typert.local 当前进程自己的 Host 或 Client reflection -ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 -ctx.typert.contexts Host Context resolver 与 Client Context binder +ctx.typert.local Host or Client reflection for this process +ctx.typert.remotes peer Remote contributions explicitly mounted by a consumer +ctx.typert.lookups providers and composition policy from wire IDs to Host objects +ctx.typert.contexts Host Context resolvers and Client Context binders ``` Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 4fbc4cc64d..50f04fd44a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -154,10 +154,10 @@ descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、e ## Typert 运行时 registry ```text -ctx.typert.local 当前进程自己的 Host 或 Client reflection -ctx.typert.remotes 消费端显式 mount 的对端 Remote contribution -ctx.typert.lookups wire ID 到 Host 对象的 provider 与组合策略 -ctx.typert.contexts Host Context resolver 与 Client Context binder +ctx.typert.local Host or Client reflection for this process +ctx.typert.remotes peer Remote contributions explicitly mounted by a consumer +ctx.typert.lookups providers and composition policy from wire IDs to Host objects +ctx.typert.contexts Host Context resolvers and Client Context binders ``` 每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 6d956d86ec..0a95947eaf 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/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: 5a82f0220058c10892b819a83499c817aa9be6ad -2026-08-03-per-session-agent-presets.zh.md: 0adcfec8b2c39a1f97d74edfa784f45062b52a99 +2026-08-03-per-session-agent-presets.md: b2195004580e1f4bf4be5527c61a8ee9b816c964 +2026-08-03-per-session-agent-presets.zh.md: 9f689c74dd570d6ec9aaa7cd1274618326a836dd diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 5a82f02200..b219500458 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -23,7 +23,7 @@ Composition splits into two planes, decided by what must be shared rather than b Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. -The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here. +The presets the deployment ships are the directories under `packages/preset/agent-presets/presets/`; the roster is that listing, not a list restated here. Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. @@ -31,7 +31,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) ## Consequences -**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than today's default, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 0adcfec8b2..9f689c74dd 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -23,7 +23,7 @@ Status: implemented 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 -部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 +部署交付哪些 preset,取决于 `packages/preset/agent-presets/presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 @@ -31,7 +31,7 @@ Status: implemented ## 后果 -**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是当下的默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 +**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index aaed5ddc6b..7f9dcea635 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 2cd09ae2daca2b15657caa18ff210fa178c2999b -2026-08-04-configuration-source-ownership.zh.md: fc47c3e47dc8d5d9ae763ddb5fe932a80e72c3d1 +2026-08-04-configuration-source-ownership.md: 1fe5908ab77632732996bd1d5c1eed9c8ab048e6 +2026-08-04-configuration-source-ownership.zh.md: 197cb936cdff303e23425d008c7a2cb738500ae0 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 2cd09ae2da..1fe5908ab7 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -40,7 +40,7 @@ inherited process environment (read-only, wins) The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. -**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer. **Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index fc47c3e47d..197cb936cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -41,7 +41,7 @@ inherited process environment (read-only, wins) 继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非机密顺序。 -**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。 **信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml index 3b80f87a89..81249420ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.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-06-web-shell-dist-chunk-layout.md -2026-08-06-web-shell-dist-chunk-layout.md: 1c7b4273dc243685317b149e2fd7fddf2a6c18d1 -2026-08-06-web-shell-dist-chunk-layout.zh.md: d1435215d3c077efc3afaac7b3a7a1c2e2ffdae7 +2026-08-06-web-shell-dist-chunk-layout.md: 5407188dab4aaebb1032b49af33731d1cbdbc6e5 +2026-08-06-web-shell-dist-chunk-layout.zh.md: 15dcc7775f05916884ed704d4c1eace9df8eaaa5 diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md index 1c7b4273dc..5407188dab 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.md @@ -24,7 +24,7 @@ The apps/web shell previously built into a single ~1.2 MB (minified) index chunk - The `assets/` root keeps only the index and vendor js (with their adjacent sourcemaps) and css. - Grammar chunks go under `assets/langs/`. The criterion is whether a chunk's `moduleIds` include an `@shikijs/langs` member, not the facade: the shared chunks of embedded grammars (php/ruby/mdx embed html+javascript, which rollup splits out for sharing) **have no facade**, so a facade criterion would miss them; index and vendor are excluded by name, because vendor legitimately carries the three boot grammars. -- Fonts go under `assets/fonts/` (`FONT_EXTENSIONS`: woff2/woff/ttf; today all of them are KaTeX faces referenced by vendor.css — katex.min.css is imported by an index-side component, but CSS modules go through manualChunks like any module and follow `katex` into vendor.css; the browser fetches only woff2, on demand and only when a formula renders). +- Fonts go under `assets/fonts/` (`FONT_EXTENSIONS`: woff2/woff/ttf; all shipped files are KaTeX faces referenced by vendor.css — katex.min.css is imported by an index-side component, but CSS modules go through manualChunks like any module and follow `katex` into vendor.css; the browser fetches only woff2, on demand and only when a formula renders). - Sourcemaps need no arrangement: rollup writes each `.map` next to its js and references it by bare relative filename, so when a chunk moves directories its map follows automatically. All cross-directory references (index's dynamic imports into `langs/`, same-directory relative references among grammar chunks, vendor.css's relative references into `fonts/`) are emitted by the bundler, so the runtime needs zero accompanying changes; the host-side webserver serves the nested paths verbatim under its static prefix. diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.zh.md index d1435215d3..15dcc7775f 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-web-shell-dist-chunk-layout.zh.md @@ -24,7 +24,7 @@ apps/web 的壳此前打成单一约 1.2 MB(minified)的 index 分片,其 - `assets/` 根只留 index 与 vendor 的 js(含随行 sourcemap)与 css。 - 语法 chunk 归 `assets/langs/`。判据是 chunk 的 `moduleIds` 含 `@shikijs/langs` 成员,而非 facade:内嵌语法共享 chunk(php/ruby/mdx 内嵌 html+javascript,被 rollup 拆出共享)**没有 facade**,facade 判据会漏;index/vendor 按名排除,因 vendor 合法携带 boot 三语法。 -- 字体归 `assets/fonts/`(`FONT_EXTENSIONS`:woff2/woff/ttf;今日全部为 vendor.css 引用的 KaTeX 字体面——katex.min.css 虽由 index 侧组件 import,css 模块同样经 manualChunks 归属、随 `katex` 落入 vendor.css;浏览器按需只拉 woff2,且仅在公式渲染时)。 +- 字体归 `assets/fonts/`(`FONT_EXTENSIONS`:woff2/woff/ttf;所有已交付文件都是 vendor.css 引用的 KaTeX 字体面——katex.min.css 虽由 index 侧组件 import,css 模块同样经 manualChunks 归属、随 `katex` 落入 vendor.css;浏览器按需只拉 woff2,且仅在公式渲染时)。 - sourcemap 无需安排:rollup 把 `.map` 写在各自 js 旁并以裸相对文件名引用,分片挪目录时 map 自动跟随。 跨目录引用(index 的动态 import 指向 `langs/`、语法 chunk 间同目录相对引用、vendor.css 相对引用 `fonts/`)均由构建器生成,运行时零配套改动;host 侧 webserver 按静态前缀原样服务嵌套路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 7a63fc4c41..c32026ebc7 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.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-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 -2026-08-08-client-tool-presentation-ownership.zh.md: 181c57a0da61795292d70b3d37ebd1485832795b +2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a +2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index 3feefc3cfb..1daad1559a 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -16,7 +16,7 @@ Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-u Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index 181c57a0da..f980db28e1 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -16,7 +16,7 @@ Client 运行时已经按 `callId` 配对工具调用/结果事件,并能从 C Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.zh.md)。`ui-conversation` 的工具 Definition 从会话事件配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方工具 identity 和拓扑,不解释具体工具名称的展示。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 业务工具插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取会话、上下文或 Conversation assembler。skill(技能)仍是普通工具;它和其他业务工具使用同一 keyed slot 注册路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 90160da7db..e7aa813ad5 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 69f92b906e46ae881b7aa6b5e46e998047fca8c2 -2026-08-09-client-conversation-node-assembly.zh.md: d075e009d9a04f20dbc8dda518e90b368b54286f +2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648 +2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 69f92b906e..e6c0e790a3 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -33,7 +33,7 @@ Registry contributions are Cordis effects. Removing a Definition causes a low-fr ### Overall `ConversationNodeDefinition` contract -Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. +Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs. One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`. @@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing ### Location is a first-class engine fact -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events. @@ -302,19 +302,19 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface ## View Builder and React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value. For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys. -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher. The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d075e009d9..702ddba001 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -33,7 +33,7 @@ Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的 ### `ConversationNodeDefinition` 总体契约 -每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 +每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。 @@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定 ### Location 是一级引擎事实 -[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 +[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 start/end Event。 @@ -302,19 +302,19 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 ## View Builder 与 React identity -[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。 -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 +[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 -[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 +[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。 `conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.i18n.yaml index a5189293df..cc9768eeac 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.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-09-cordis-event-walk-backstop.md -2026-08-09-cordis-event-walk-backstop.md: c031b7c444a4d9bdfb0792523ba50e297f53b56a -2026-08-09-cordis-event-walk-backstop.zh.md: 316a79d774491b723c85ed65402d724e169ca66f +2026-08-09-cordis-event-walk-backstop.md: 5a85c5df2705c69f7a045e04a32345149135c427 +2026-08-09-cordis-event-walk-backstop.zh.md: 17660d90bd60c770b63eec95c42b0aeacf269d43 diff --git a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md index c031b7c444..5a85c5df27 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md +++ b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.md @@ -24,7 +24,7 @@ The audit that motivated this found the host face already complete: 48 rendered ## Verification -`scripts/gen-cordis-catalog-partition.spec.ts` proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, rendered surface the scan cannot see (the third direction), and the scan reaching nested Events-only merges, every block of a multi-block file, double-quoted heads, and `.tsx` sources. Deleting one live exemption from the real tree makes `gen-cordis-catalog` fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover today's surface. `verify-cordis-catalog` in doc-sync executes the partition on every run. +`scripts/gen-cordis-catalog-partition.spec.ts` proves each acceptance path: the green partition, an invisible unexempted event (named with its declaring file), a stale rendered-event exemption, a stale never-declared exemption, the service mirror of each, unmapped rendered surface in both page maps, rendered surface the scan cannot see (the third direction), and the scan reaching nested Events-only merges, every block of a multi-block file, double-quoted heads, and `.tsx` sources. Deleting one live exemption from the real tree makes `gen-cordis-catalog` fail loud with the event's name and declaring file; restoring it returns the generator to a byte-identical no-op regeneration (85 artifacts, 0 written), which also proves the new exemptions exactly cover the scanned surface. `verify-cordis-catalog` in doc-sync executes the partition on every run. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.zh.md b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.zh.md index 316a79d774..17660d90bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-cordis-event-walk-backstop.zh.md @@ -24,7 +24,7 @@ Status: implemented ## 验证 -`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面、扫描看不到的已渲染表面(第三方向),以及扫描触达嵌套的仅含 Events 的 merge、多块文件的每个块、双引号头部与 `.tsx` 源文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件显式报错;恢复后生成器回到字节相同的 no-op 再生成(85 个产物,写入 0 个),这同时证明新豁免恰好覆盖当下表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。 +`scripts/gen-cordis-catalog-partition.spec.ts` 证明每条验收路径:绿色分区、不可见且未豁免的事件(报出声明文件)、已渲染事件的陈旧豁免、从未声明的陈旧豁免、服务侧的对称路径、两个页面映射中未映射的已渲染表面、扫描看不到的已渲染表面(第三方向),以及扫描触达嵌套的仅含 Events 的 merge、多块文件的每个块、双引号头部与 `.tsx` 源文件。在真实源码树上删除一条现役豁免会让 `gen-cordis-catalog` 以事件名与声明文件显式报错;恢复后生成器回到字节相同的 no-op 再生成(85 个产物,写入 0 个),这同时证明新豁免恰好覆盖扫描到的表面。doc-sync 中的 `verify-cordis-catalog` 每次运行都会执行该分区检查。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index d9ad41a205..68c4aa33d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: cf6b4a92a6e9b390c7fcaca17f56b4c652cc9319 -2026-08-09-headless-direct-core-entry-point.zh.md: 9f45fcdaf87ddcccbd331eeacce0dc7035c61f19 +2026-08-09-headless-direct-core-entry-point.md: 8ed979794afa008588d1b849f0074e8696e6e43f +2026-08-09-headless-direct-core-entry-point.zh.md: 512d4b88c921431fe26afd9f62c34a1939ac5bdd diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index cf6b4a92a6..8ed979794a 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -12,7 +12,7 @@ The direct entry point still needs the same deployment model state as Web-create ## Decision -The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. `headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index 9f45fcdaf8..512d4b88c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。headless 组合包提供自身的 persona 与工具模式、禁用 HMR(热模块替换)、显式挂载 Code Mode worker,并插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml index 50cdedbe5b..d9823a5e88 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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-10-cancelled-stream-prefix-finalize.md -2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c +2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md index 0cae25b786..fd397a0266 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -36,4 +36,4 @@ Terminal provider errors still discard their streamed prefix. That asymmetry rem ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md index e961ea6a51..44adb2ff41 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -36,4 +36,4 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted` ## Testing -`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index cb8b03e051..79ed44e6a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-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-10-host-plane-ownership-after-presets.md -2026-08-10-host-plane-ownership-after-presets.md: f6a2604cdd8be59296fada148f47dea7503356a7 -2026-08-10-host-plane-ownership-after-presets.zh.md: 6e9b078c9cc46b1167b9fa92c9d8222ecc492530 +2026-08-10-host-plane-ownership-after-presets.md: 78410368cdc66b14fe478a4de9f0ae7a1cbefac5 +2026-08-10-host-plane-ownership-after-presets.zh.md: 862b793fab23b209f6f43b2e8045ad7330c30ba0 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md index f6a2604cdd..78410368cd 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -32,7 +32,7 @@ Three limits stay open and are recorded where they bite rather than fixed here: **Keep the meter in the preset and scope-layer the projection registry.** The precise fix, and much larger: `snapshot`, `checkpoint`, and the eager drive would each need a session→scope resolution that a cold read does not have without the api-proxy's `presenterScopeFor`. Rejected as disproportionate to one Service with no per-preset state at all; the general rule is documented on the registry instead. -**Veto publication for an unjoined agent.** Loud beats silent, and the registry supports it — a synchronous `agent/created` listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: `recompose` documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one today. A veto would convert a capability gap into an outage. +**Veto publication for an unjoined agent.** Loud beats silent, and the registry supports it — a synchronous `agent/created` listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: `recompose` documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one. A veto would convert a capability gap into an outage. **Check the join at `agent/created` in the companion too.** Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path. Prompt assembly can distinguish them. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md index 6e9b078c9c..862b793fab 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -32,7 +32,7 @@ Status: implemented **把 meter 留在 preset,改为给投影注册表分层。** 这是更精确的修法,代价也大得多:`snapshot`、`checkpoint` 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 `presenterScopeFor` 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。 -**对未加入的 agent 否决发布。** 大声胜过静默,注册表也支持这么做——同步的 `agent/created` 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——`recompose` 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 今天都会创建一个。否决会把能力缺口变成一次故障。 +**对未加入的 agent 否决发布。** 大声胜过静默,注册表也支持这么做——同步的 `agent/created` 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——`recompose` 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 都会创建一个。否决会把能力缺口变成一次故障。 **让配套也在 `agent/created` 处检查加入情况。** 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。提示词组装分得清。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index f068885f5a..33a31a1363 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: ee3d9884b53f5fa5d0b0072660888c5f4d283b1b -2026-08-10-remote-event-delivery.zh.md: 01777bd8818d72cfc9650b9d35bc5ae5ee82d880 +2026-08-10-remote-event-delivery.md: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429 +2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index ee3d9884b5..5e6e04bdf2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -1,4 +1,4 @@ -# Agent Note: Remote event delivery (ctx.remote.$on) +# Agent Note: Remote event delivery (`ctx.remote.$on`) Status: implemented @@ -6,39 +6,51 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md) ## Problem -[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. +[Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain. -The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. +The Host owns one-way events such as `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. They do not depend on AgentScope, and their payloads are already JSON. Requiring every event to cross a handwritten API Proxy frame, a handwritten Client Runtime bridge, and a Client event alias adds no fact beyond the owner event declaration. -That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. +That duplicate declaration is also lossy: the Client side restates an event as `settings/changed(ns: string)`, flattening a branded type to bare `string`, contrary to the Remote-method rule that consumer types point to the business package's one canonical symbol. ## Decision -The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim: +The consumer Remote surface has one event-subscription verb, `ctx.remote.$on(event, listener)`, with allowlist-driven, verbatim forwarding: -- `packages/api/remotes/src/remote-events.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. `src/types.ts` beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration. -- The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming. -- The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink. -- Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof. -- Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to Typert. +- `packages/api/remotes/src/remote-events.ts` owns one list of forwardable Host events with explicit `emit`/`waterfall` modes. It is also the sole control point for what consumers may subscribe to. Adjacent `src/types.ts` derives the type projection and fills the selection seat while remaining type-only. Both files appear in the `files` of the package's Host and Client faces, so both read one declaration. +- The event name on the wire is the original Host Cordis name (`settings/document-updated`) without a `host/` prefix. The payload is the Host argument list, element for element through JSON, without projection, redaction, or renaming. +- `api/remotes` registers the Host source with API Gateway. Gateway reserves internal logical endpoint `$events` on the existing `/api/remote.mux`, adding no physical connection and giving API Proxy no event interpretation. Waterfall results return through HTTP unary endpoint `$events/result`. +- Event signatures have no second table. Owner packages place their Cordis `Events` declarations in Client-safe, type-only `./types` exports so both faces read the same declaration. `$on` listener parameters, result, and `next()` derive from `Events[Event]`; verbatim correspondence holds by construction. +- Only Cordis's type declarations are shared. Delivery semantics, registration, and failure handling belong to Typert. -When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the five packages needs that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, `CredentialRef`, and `SessionId`, all pure types. The agent-presets package renames its previous vocabulary module to `preset.ts`, leaving the exported `types.ts` dedicated to the client-safe event declaration. +When an `Events` member reaches a Host-only symbol such as a Service, `Agent`, or Context, the code is split until the declaration can live cleanly in `./types`. A declaration is never split between `index.ts` and `types.ts`, and `types.ts` does not invent a structurally equivalent shadow type. Every current owner exposes its selected event declaration from a Client-safe type export. -All five events ride this path, and their dedicated `HostFrame` variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, `llm/adapters-updated` and `settings/document-updated`; preset-derived consumers subscribe to `agent-preset/selected`. Frames that actually project or deduplicate data stay dedicated: `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). +All allowlisted events use this path, and dedicated frames and Client aliases are removed. Model consumers subscribe directly to `llm/adapters-updated` and `settings/document-updated`; preset consumers subscribe to `agent-preset/selected`; stateless Session and dynamic-Cordis notifications use `emit`; Approval and Question use Agent-scoped `waterfall`. Data that needs a baseline, projection, or deduplication retains a dedicated Remote stream. -`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no consumer today**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat. +`skills/change`, `tools/change`, and `system-prompt/change` have the same pure invalidation form but no shipped consumer. The rule that every abstraction needs a current owner and need keeps them outside the allowlist; they remain only an extension point recorded here. -### Consumer contract (dsh-typert-protocol) +### Consumer contract (`dsh-typert-protocol`) -type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypertClientRemote`. No runtime code: +Type metadata adds event-form predicates, mode entries, a selection seat, and one member of `TypertClientRemote`, with no runtime code: -```ts +```ts ignore-check import type { Events } from '@deepseek-ai/cordis' -/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + +/** Cordis event names that can cross the Remote Event carrier without a second signature. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] @@ -51,126 +63,136 @@ export type TypertRemoteEvent = Extract(event: Event, listener: Events[Event]): () => void +$on(event: Event, listener: TypertClientEventListener): () => void ``` -`Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client. +`Events` resolves per program: the complete Host event vocabulary in a Host program and only declarations visible to the Client compilation face in a Client program. The same predicate therefore holds on both sides without bringing Host declarations into the Client. -**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from the implicit `PLATFORM_MODULES` plus `PRELOADED_CLIENT_EXTERNALS` baseline, the package's `dsh.client.external` requests, the `INLINE_SAFE` wire layer, and generated `/remote` contributions. Inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes: +**The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event. + +Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners. + +A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. + +The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events. + +### The allowlist: one declaration read by both faces + +`packages/api/remotes/src/remote-events.ts` appears in both `tsconfig.host.json` and `tsconfig.client.json` and is the allowlist's sole home. `src/types.ts` derives the type face: ```ts ignore-check -$dispatch(event: string, args: readonly unknown[]): void -``` - -client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The `event` parameter is `string`, not `TypertRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently. - -Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks). - -### The allowlist: one declaration both faces read - -`packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face: - -```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] // types.ts — the type face, derived -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} } ``` -Forwarding one more event is therefore **one line in that array**: the type projection, `$on`'s key surface, and the Host forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` (a Client-local event) and `$on('skills/change', …)` (declared but unselected) are both **compile errors**. +Adding an event is therefore one array entry: type projection, the `$on` key set, Host dispatch mode, and the forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` for a Client-local event and `$on('skills/change', …)` for a declared but unselected event are compile errors. -The Host face adds one shape assertion, binding the Host event vocabulary to that same array: +The declaration's trailing `satisfies` applies Host event-vocabulary and mode constraints to the same allowlist: ```ts ignore-check -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[] ``` -It is an expression statement rather than a named constant, which `noUnusedLocals` would reject (the underscore prefix exempts parameters only). It enforces three things: the **name is real** (the predicate is keyed on `keyof Events`), the event **binds no Scope** (`goal/changed` and kin have a `ThisParameterType` other than `unknown` and drop out — the static expression of "no AgentScope dependency"), and the event is **one-way** (a non-`void` return, i.e. a waterfall/bail shape, drops out). +It enforces three properties: the name exists because the predicate is keyed by `keyof Events`; the selected mode matches the signature; and the signature is either an unscoped `void` notification or a waterfall with top-level Agent scope, a same-result `next()`, and a Promise return. Other Scope, bail, parallel, and serial forms are excluded. -**"Verbatim" is proved nowhere because it holds by construction**: `$on`'s listener type comes from the one cordis `Events` declaration in the owner package's `./types`, and Host forwarding reads that same declaration. There is no second declaration that could drift. +Verbatim correspondence is not proved separately because it holds by construction. `$on`'s listener type and Host forwarding both read the owner package's one Cordis `Events` declaration, so no second declaration can drift. -JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with `dsh-session`'s `isJsonValue` and **throws loudly** when one fails, because that is an allowlist composition mistake rather than untrusted input. +JSON safety remains a runtime concern. Before queueing, the API Remotes Host source checks every argument with `dsh-session`'s `isJsonValue` and fails loudly when one is invalid, because this is an allowlist composition error rather than untrusted input. -### Wire contract (apiproxy) +### Wire protocol (API Gateway Remote mux) ```ts ignore-check -| { type: 'host/remote-event'; event: string; args: JsonValue[] } +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } ``` -The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`. +The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events. -`events.host()` subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed. +The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. -`api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-input-trigger` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`. +`$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only. -### The apps/web browser e2e belong to the Host face +### The `apps/web` browser e2e belongs to the Host face -The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: they boot a real harness in-process and read `ctx.apiProxy`, the Host `SessionStore`'s `get`/`create`/`flush`, and `ctx.sessionProjectionCache`. **Driving a browser at runtime does not make a file part of the Client program** — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key. +The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key. -That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, `ui-commands`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc. +This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc. -The few Client-owned symbols are therefore **mirrored** on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures. +The few required Client symbols are mirrored on the test side: `scaffold.ts` exports the mirrored welcome-notice constants, while the two chat e2e files import `dsh-client-runtime/client` directly because the Runtime project already belongs to the Host graph. This removes those four consumers from the Host graph, and the 15 Client project references in `apps/cli/tsconfig.json` no longer serve an owner-map role. Each mirror is byte-identical to its source; drift produces a selector mismatch or an unsuppressed notice and fails loudly. ### Change inventory | Location | Change | |---|---| -| `dsh-typert-protocol` | `src/types.ts` gains `TypertForwardableEvent`, `TypertRemoteEventSelection`, and `TypertRemoteEvent`; `TypertClientRemote` gains `$on` and `$dispatch`. Types only, no runtime | -| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) | -| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the five owner `./types`; the Client half re-exports those five plus `@deepseek-ai/dsh-api-gateway/client` | -| Root `tsconfig.base.json` | Client-safe `paths` entries for settings, credentials, llm, agent-presets, and api-remotes types point at the **source** plane | -| `dsh-commands` / `dsh-settings` / `dsh-credentials` / `dsh-llm` / `dsh-agent-presets` | Each forwarded `interface Events` member lives in the owner's client-safe `./types`; agent-presets moves its previous domain vocabulary to `preset.ts` so the exported file itself remains `types.ts` | -| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses the five dedicated passthrough or invalidation variants with their zod branches; `events.host()` subscribes by allowlist and validates through `assertJsonArgs` | -| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath | -| `client/runtime` | The five Client-event bridge branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection and deleting their duplicated `Events` declarations | -| Seven consumers | ui-commands / ui-model-selection / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | -| `client/connection` | The fixture's `emitHost` produces `host/remote-event` | -| `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references | +| `dsh-typert-protocol` | `src/types.ts` provides forwardable-mode derivation, selection, and Client-listener projection; `TypertClientRemote` exposes only `$on`. Types only, no runtime | +| `api/gateway` | Host provides one Remote event source, `$events`, pending-waterfall coordination, and `$events/result`; Client registers the private pump as the Connection generation source and owns frame validation and Cordis dispatch | +| `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions | +| Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` | +| `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files | +| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation | +| `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source | +| `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established | +| Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` | +| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source | +| `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` | ## Alternatives considered -**Open a general downlink channel for Remote events** (the push counterpart of `ctx.connection.rpc`, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, `WebApiClient`, `ConnectionController`, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change. +**Continue using API Proxy's Host downlink.** This reuses Connection generation and `connection/reset` but leaves the Remote event allowlist, queue, schema, and Client Runtime bridge in API Proxy and prevents domain transports from sharing the lifecycle of other Remote streams. With API Gateway's resident `/api/remote.mux`, `$events` adds only one internal logical stream and belongs naturally in Gateway. -**Declare a separate `TypertRemoteEventMap` in type-meta and let owner packages merge into it.** The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis `Events`, requiring a bidirectional `extends` proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one `Events` declaration makes that equivalence structural, so the table is not created. +**Open a third physical WebSocket or duplex stream for Remote events.** An independent channel could own connection state but would duplicate authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect backoff already provided by Gateway mux. Internal `$events` retains an independent logical stream, while waterfall results reuse HTTP unary calls. -**Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection. +**Declare a separate `TypertRemoteEventMap` in type metadata and let owner packages declaration-merge into it.** The consumer key set would exactly equal remotely deliverable events, but every signature would be written again outside Cordis `Events`, requiring a bidirectional equivalence proof and new type-metadata dependencies for owner packages. Sharing one `Events` declaration makes equivalence structural, so the second map is not created. -**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed. +**Have the Typert generator project Host `Events` declarations.** The generator already analyzes Host events, but it cannot infer projection or redaction intent and would expand the generator and build surface. Verbatim forwarding needs no projection. -**Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`. +**Give forwardable events a payload projection function.** A `{ event, project, zod }` table could combine model-directory inputs and derive Workspace views, but would manually align projection logic with payload types and recreate the central table removed from Remote methods. -**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. **Assessed and declined.** +**Move the `apps/web` browser e2e into the Client aggregate.** The intuition that browser tests belong to the Client face fails with 21 errors because the tests use Host services while the Client program's `ctx.sessions` is `ISessions`. + +**Split `directory-picker-browse`/`-native` into Host and Client faces.** This would remove Client packages from the Host graph, but changes another owner's packages for only a cleaner build graph. Mirroring the required Client symbols on the test side removes the need for that split. ## Verification -What pins this behavior: - -- A real composition test puts one `host/remote-event` frame on the real host stream per Host emit, with `event` the Host name and `args` equal element for element. -- Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. `$on('slots/changed', …)` (Client-local) and `$on('skills/change', …)` (declared but unselected) both fail to compile, so `$on`'s key surface equals the allowlist. -- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire. -- `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration. -- Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame. -- `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload. -- The five dedicated `HostFrame` variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event. +- A real Host-source composition test proves that two Client streams each receive `{ event, args }`, disconnecting one does not affect the other, and non-JSON arguments fail loudly without poisoning later valid delivery. +- Type negatives reject unselected events, non-`void` unscoped events, non-Agent-scoped waterfalls, and allowlist modes that disagree with signatures. `$on('slots/changed', …)` and `$on('skills/change', …)` both fail to compile, so `$on`'s key set equals the allowlist. +- Consumer `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`, preserving the brand across the wire. +- A `$on` disposer belongs to the calling fiber, and registering the same function object twice produces independently removable registrations; subscriptions are addressed by registration rather than listener identity. +- Ordinary notifications contain both a throwing listener and a listener returning a rejected Promise. Waterfall tests pin Client result, `next()`, rejection, cancellation, first claim across multiple Clients, and reconnect replay of a pending request. +- Gateway tests cover missing, duplicate, and withdrawn sources; payload rejection; ready-before-event ordering; and browser and in-process carriers. Client tests cover generation-source registration, description/increment readiness order, reopen after physical failure, Host errors and unexpected completion, non-ready opening items, malformed event items, `$events/result` failure, and disposal quiescence. +- `host/remote-event`, public `$dispatch`, the Client Runtime bridge, and API Proxy's allowlist dependency are absent; consumers observe owner events directly. ## Consequences -- **Tenancy inside a legacy frame union.** The contract lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change. -- **Two files break api/remotes' face-disjointness contract.** `src/remote-events.ts` and `src/types.ts` belong to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (the `paths` entry points at source). -- **The carrier handoff is developer-visible.** Any Client plugin holding `ctx.remote` can call `$dispatch` and synthesize a forwarded event. That exposure predates the verb — `ctx.emit` was equally reachable while an internal event relayed the frame — and matches what `connection/reset` already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-`$on` conversion and do not pretend the port authenticates its caller. -- **A malformed argument fails in the emitter's containment, not at load.** `assertJsonArgs` throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point. -- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. The rule lives in `apps/web/tests/README.md` and is held by review — a grep-level gate was considered and deliberately skipped. -- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope. -- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing `dsh-client-runtime/client` rely on `runtime` already being in that graph — incidental, not a guarantee. -- **The invariant companion holds no runtime check.** An earlier revision asserted the dispatch shape (`thisArg === null`, `mode === 'emit'`) over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's `TypertForwardableEvent` assertion already refuses both deviations at compile time, so the companion is an explained empty installer. +- **Gateway has one non-generated endpoint.** `$events` has no business namespace and does not enter the Typert descriptor. It is the internal connection point between Gateway and API Remotes and defines the Client Connection generation lifetime. Strict empty-payload validation, opening-ready validation, and single-source registration prevent it from becoming another handwritten business API. +- **Two files break API Remotes' face-disjointness rule.** `src/remote-events.ts` and `src/types.ts` belong to both projects and emit identical declarations into shared `lib/types`. Their content is byte-identical and `.tsbuildinfo` files remain separate, so this is safe in practice; the README records why source-plane `paths` require the exception. +- **Producer operations remain private.** Business plugins can call only `$on`. Host-source registration and Client dispatch are absent from `TypertClientRemote`; test doubles drive subscriptions through their own `emit` operations rather than impersonating a production API. +- **Malformed arguments fail at emit.** An API Remotes listener throws before queueing, so Host `ctx.emit` immediately observes an allowlist composition error and the queue can still deliver subsequent valid events. +- **Test-side mirrors can drift.** No mechanism compares mirrored Client constants under `apps/web/tests` with their source. Drift instead produces a selector mismatch. `apps/web/tests/README.md` records the review rule; a grep-level gate is deliberately omitted. +- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending. +- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. +- **The invariant companion intentionally has no runtime check.** A prior revision asserted delivery form on the live event bus, coupling the companion to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, so the companion is an explained empty installer. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 01777bd881..d744b92d47 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -6,39 +6,51 @@ Status: implemented ## 问题 -[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 +[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)最初只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的事件需要一个不归 API Proxy 领域所有的投递机制。 -Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 +Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 等单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。若每条事件都要穿过 API Proxy 手写帧、Client Runtime 手写桥和 Client 事件别名才能抵达 UI,这些层不会陈述 owner 事件之外的新事实。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 ## 决策 -消费端 Remote 面持有一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: +消费端 Remote 面持有一个事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: -- `packages/api/remotes/src/remote-events.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 +- `packages/api/remotes/src/remote-events.ts` 持有一份带 `emit`/`waterfall` mode 的可转发 Host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 Host 与 Client 两个 face 的 `files`**,两侧读同一份。 - wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。 -- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。 -- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 +- Host source 由 `api/remotes` 注册到 API Gateway;Gateway 在既有 `/api/remote.mux` 上保留内部 logical endpoint `$events`,不增加物理连接,也不让 API Proxy 解释事件。waterfall 结果通过 HTTP 一元 endpoint `$events/result` 返回。 +- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 参数、结果和 `next()` 都由 `Events[Event]` 推导。「原样」不需要证明,是构造性成立的。 - 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 Typert 自己。 -一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 `preset.ts`,让导出的 `types.ts` 专门承载 client-safe 事件声明。 +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。当前名单内各 owner 都从 client-safe 类型出口提供同一份事件声明。 -五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`。真正需要投影或去重的数据仍保留专用帧。 +名单内事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`;Session 与动态 Cordis 的无状态通知使用 `emit`;Approval 与 Question 使用 Agent-scoped `waterfall`。真正需要 baseline、投影或去重的数据仍保留专用 Remote stream。 -`skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但目前**没有任何消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 +`skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但**没有任何已交付消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 ### 消费端契约(dsh-typert-protocol) -type-meta 加一个**形状谓词**、一个**选择座位**和 `TypertClientRemote` 的**一个**成员;零运行时代码: +type-meta 加事件形状谓词、mode 条目、选择座位和 `TypertClientRemote` 的一个成员;零运行时代码: -```ts +```ts ignore-check import type { Events } from '@deepseek-ai/cordis' -/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ +type TypertForwardingMode = + unknown extends ThisParameterType + ? TypertEventResult extends void ? 'emit' : never + : TypertWaterfallEvent extends never ? never : 'waterfall' + +/** Cordis event names that can cross the Remote Event carrier without a second signature. */ export type TypertForwardableEvent = { - [Event in keyof Events]: unknown extends ThisParameterType - ? ReturnType extends void ? Event : never + [Event in keyof Events]: TypertForwardingMode extends never ? never : Event +}[keyof Events] + +/** Event and dispatch mode accepted by the Remote Event source. */ +export type TypertForwardableEventEntry = { + [Event in keyof Events]: TypertForwardingMode extends infer Mode + ? Mode extends 'emit' | 'waterfall' + ? { readonly event: Event; readonly mode: Mode } + : never : never }[keyof Events] @@ -51,69 +63,78 @@ export type TypertRemoteEvent = Extract(event: Event, listener: Events[Event]): () => void +$on(event: Event, listener: TypertClientEventListener): () => void ``` `Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 -**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行隐式的 `PLATFORM_MODULES` 加 `PRELOADED_CLIENT_EXTERNALS` 基座、包自身的 `dsh.client.external` 请求、`INLINE_SAFE` wire 层与 `/remote` 生成物值导入。靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: +**契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -```ts ignore-check -$dispatch(event: string, args: readonly unknown[]): void -``` +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 -持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。`event` 形参是 `string` 而非 `TypertRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 -投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。 +Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。 ### 名单:两个 face 共读的同一份声明 `packages/api/remotes/src/remote-events.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**;`src/types.ts` 由它派生类型面: -```ts +```ts ignore-check // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/reference-updated', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] // types.ts — the type face, derived -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} } ``` -于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、host 的转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。 +于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、Host dispatch mode 与转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(Client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。 -host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上: +数组声明末尾的 `satisfies` 把 Host 事件词汇与 mode 约束落到同一份名单上: ```ts ignore-check -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[] ``` -写成表达式语句而不是命名常量:后者会被 `noUnusedLocals` 判为未使用(下划线前缀只豁免参数)。它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**不绑 Scope**(`goal/changed` 那族的 `ThisParameterType` 不是 `unknown`,被排除——「不依赖 AgentScope」的静态表达)、**单向**(非 `void` 返回的 waterfall/bail 形状被排除)。 +它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**mode 匹配签名**,以及只接受无 scope 的 `void` 通知或带一级 Agent scope、同结果 `next()` 和 Promise 返回的 waterfall。其他 Scope、bail、parallel 与 serial 形状都被排除。 **「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。 -载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 +载荷 JSON-safe 交给运行时:`api/remotes` 的 Host source 在入队前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 -### 线协议(apiproxy) +### 线协议(API Gateway Remote mux) ```ts ignore-check -| { type: 'host/remote-event'; event: string; args: JsonValue[] } +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } ``` -zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。 +Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -`events.host()` 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。 +Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 - -`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-input-trigger` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 +`$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 @@ -127,21 +148,23 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 | 位置 | 改动 | |---|---| -| `dsh-typert-protocol` | `src/types.ts` 加 `TypertForwardableEvent`、`TypertRemoteEventSelection`、`TypertRemoteEvent`;`TypertClientRemote` 增 `$on` 与 `$dispatch`。纯类型,零运行时 | -| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅按注册项寻址、`ctx.effect` 归属调用方 fiber)与 `$dispatch`(快照后按注册顺序派发,收容抛出或拒绝的 listener) | -| `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` | +| `dsh-typert-protocol` | `src/types.ts` 提供 forwardable mode 推导、selection 与 Client listener 投影;`TypertClientRemote` 只公开 `$on`。纯类型,零运行时 | +| `api/gateway` | Host 半提供唯一 Remote event source、`$events` stream、pending waterfall 协调和 `$events/result`;Client 半把私有 pump 注册为 Connection generation source,负责 frame 校验和 Cordis 分发 | +| `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删除五个专用变体及其 zod;`events.host()` 按名单挂监听并通过 `assertJsonArgs` 校验 | -| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | -| `client/runtime` | 五条 Client 事件桥分支收敛为 `ctx.remote.$dispatch(frame.event, frame.args)`,并删除重复声明 | -| 5 个消费者 | ui-commands / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | -| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | +| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | +| `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | +| `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | +| 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 -**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。 +**继续寄生 API Proxy 的 Host downlink。**这样可以复用 Connection generation 和 `connection/reset`,但会让 API Proxy 保留 Remote 事件 allowlist、队列、schema 和 Client Runtime bridge,领域传输也无法随其他 Remote stream 共用生命周期。API Gateway 已有常驻 `/api/remote.mux` 后,`$events` 只增加一个 internal logical stream,不需要第三条 WebSocket,因此转移到 Gateway 的成本和所有权都更合理。 + +**给 Remote 事件另开第三条物理 WebSocket 或 duplex stream。**独立通道能拥有自己的连接状态,但会重复 Gateway mux 已经提供的认证升级、复用、取消、错误映射和退避重连。内部 `$events` endpoint 保留独立 logical stream,waterfall 结果复用 HTTP 一元调用。 **在 type-meta 立一张独立的 `TypertRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。 @@ -157,21 +180,21 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 钉住该行为的东西: -- 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等。 -- 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(`goal/changed`)、返回值非 `void` 的事件。`$on('slots/changed', …)`(client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 +- Host source 真组合测试:两个 Client stream 各自收到 host emit 的 `{ event, args }`,其中一个断开不会影响另一个;非 JSON 实参会响亮拒绝且不会毒化后续合法事件。 +- 类型层负例拒绝未选择事件、非 `void` 的无 scope 事件、非 Agent-scoped waterfall,以及声明 mode 与签名不符的条目。`$on('slots/changed', …)`(Client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 - 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。 - `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。 -- 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。 -- `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。 -- 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。 +- 普通通知同时收容抛出的 listener 与拒绝所返回 Promise 的 listener;waterfall 测试固定 Client result、`next()`、拒绝、取消、多 Client 首个 claim 和重连重放 pending request。 +- Gateway 测试覆盖 source 缺失、重复注册、撤销中止、payload 拒绝、ready 先于事件,以及浏览器与进程内两种 carrier;Client 测试覆盖 generation source 注册边界、描述与增量就绪顺序、物理失败后重开、Host 错误与意外结束、非 ready 首项、畸形事件项、`$events/result` 失败和 dispose quiescence。 +- `host/remote-event`、公开 `$dispatch`、Client Runtime bridge 和 API Proxy 的 allowlist 依赖都不存在;各消费方直接观察 owner 事件。 ## 后果 -- **寄居在 legacy 帧联合里**:契约住在 apiproxy 的 `HostFrame` 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 `api-remotes`,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。 +- **Gateway 有一个非生成 endpoint**:`$events` 不对应业务 namespace,也不进入 Typert descriptor;它是 Gateway 与 `api/remotes` 之间的内部连接点,同时定义 Client Connection generation 的存活期。严格的空 payload 校验、opening ready 校验和单 source 注册限制它不会演化成第二个手写业务 API。 - **两个文件打破了 api/remotes 的 face 互斥约定**:`src/remote-events.ts` 与 `src/types.ts` 同属两个工程,各自向共享的 `lib/types` 发射一份相同声明。内容逐字节相同、`.tsbuildinfo` 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(`paths` 指向源码面)。 -- **载体交接是开发者可见的**:任何持有 `ctx.remote` 的 client 插件都能调 `$dispatch` 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,`ctx.emit` 同样可达——与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 `$on` 的转换」,不假装该端口鉴别调用方。 -- **畸形实参在发射方的收容里失败,而非加载期**:`assertJsonArgs` 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。 +- **生产方保持私有**:业务插件只能调用 `$on`;Host source 注册和 Client 派发都不在 `TypertClientRemote` 上暴露,测试 double 以自己的 `emit` 方法驱动订阅,不伪装成生产接口。 +- **畸形实参在 emit 点失败**:`api/remotes` listener 在入队前抛出,因此调用 Host `ctx.emit` 的操作立即看到名单配置错误;队列仍可继续投递后续合法事件。 - **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。 -- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放——这些都是纯失效信号,且 `connection/reset` 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。 +- **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypertForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index fa18284fad..85793a0b5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7 -2026-08-10-session-log-version-mechanism.zh.md: ac1088527ba14a8018c5a7fb3c77c9232bd3b3a9 +2026-08-10-session-log-version-mechanism.md: 81108ceaf23405c8f2def9aaef88505d635808a3 +2026-08-10-session-log-version-mechanism.zh.md: cbb127420e2695853fdc2ad0bb98a7a0bf230b5b diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 25eb1230a2..81108ceaf2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first ## Consequences -What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index ac1088527b..cbb127420e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 影响 -v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml index 9b266cdcde..c7efb03563 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.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-11-loader-entry-disabled-interpolation.md -2026-08-11-loader-entry-disabled-interpolation.md: fd760ea0f15f19e5f287aaddc36fb8eeb5f519ba -2026-08-11-loader-entry-disabled-interpolation.zh.md: 41929a58b2e34974596ddb7f5b6748f14bd82e2d +2026-08-11-loader-entry-disabled-interpolation.md: 2d8fb73d95bab53367209afb2a9479a1b94cb949 +2026-08-11-loader-entry-disabled-interpolation.zh.md: 292b191f8293d31b53a7d5afa1f7d8017b31eeb4 diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md index fd760ea0f1..2d8fb73d95 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.md @@ -16,7 +16,7 @@ The mechanism completes the platform-layer fold: the base bundle's `cordis.patch ## Alternatives considered -**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. +**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, while platform is only one deployment condition. **Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows. diff --git a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md index 41929a58b2..292b191f82 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -16,7 +16,7 @@ Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`) ## 备选方案 -**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 +**行上的声明式 `platform` 字段。**静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,而平台只是众多部署条件之一。 **预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 80a1959b5a..b1391a43f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.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-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 -2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f +2026-08-11-pwsh-persistent-pty.md: 4c523d3c7c45e6d86942868df92b981576e76859 +2026-08-11-pwsh-persistent-pty.zh.md: 4f87490fd60ecc37ad9390e0ce990173bbafc3b8 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 8353b3ab3c..4c523d3c7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -24,7 +24,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### Shell dialect in `@deepseek-ai/dsh-terminal-bash` -One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`; the bash argv and environment defaults remain unchanged). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and accepts only the backend's `stdin_read` result; a printable prompt literal in echoed setup input is not readiness. One `timeoutMs` deadline owns the complete startup retry loop, so `inferred_idle` follow-up sends cannot restart the bound. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and emits terminal-protocol replies through `SubprocessTerminalHandle`; the backend drains those writes before caller input and accepts foreground state only when protocol work stayed quiet throughout inspection, so a caller's input cannot be consumed as a cursor-position response. One parser write stays active while later raw chunks coalesce into the next batch, preventing high-volume output from creating one scheduled parse per chunk. The existing sanitizer and bounded buffers remain the output projection. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic stay shared — the marker remains a readiness signal with an unconsumed payload, and the deferred BEL event channel stays deferred. ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode. The session suite pins split cursor-position queries, response-write ordering, and parse batching without a real shell; real-pwsh suites on macOS and Windows prove persistent cwd/env, secret scrubbing, UTF-8 output, multiline and here-string commands, large-output clipping, and exit/reset. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -46,6 +46,7 @@ The Windows test surface follows master's exemption structure: terminal-bash and - **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. - **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. - **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Hand-written cursor-position replies.** Rejected: the response must reflect cursor movement, wrapping, and control sequences already emitted by the shell. Fixed coordinates amplify console redraws and can exhaust bounded output; `@xterm/headless` maintains that protocol state without replacing the line-oriented output projection. - **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. - **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. - **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. @@ -62,4 +63,6 @@ The Windows test surface follows master's exemption structure: terminal-bash and **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. -**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. +**Terminal protocol replies precede caller input.** The headless emulator retains no scrollback and contributes no model-visible text; it tracks terminal control state and emits replies through the mounted subprocess provider. This adds the maintained `@xterm/headless` runtime dependency and prevents a cursor query from consuming a later tool command. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; if marker readiness remains unavailable, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. A later model redefinition of the `prompt` function degrades command readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi and `@xterm/headless` add process-substrate and terminal-backend dependency review respectively. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 95048a0241..4f87490fd6 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -24,7 +24,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 -一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`;bash 的 argv 和环境默认值保持不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并且只接受 backend 的 `stdin_read` 结果;回显引导输入中的可打印提示符字面量不代表就绪。一条 `timeoutMs` 绝对超时计时器负责限制完整启动重试循环,因此 `inferred_idle` 后续 send 无法重新计时。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过 `SubprocessTerminalHandle` 发出终端协议响应;backend 会在调用方输入前排空这些写入,并且只接受协议工作在整次检查期间保持静止时的前台状态,因此调用方输入不会被当作光标位置响应而消费。一个 parser 写入保持活跃,随后到达的原始 chunk 会合并为下一批,从而避免高输出量为每个 chunk 分别调度解析。现有 sanitizer 与有界缓冲区仍负责输出投影。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 `PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑保持共享——标记仍是载荷不被消费的就绪信号,延后的 BEL 事件通道也继续保持延后。 ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式。session 套件无需真实 shell 即可固定拆分的光标位置查询、响应写入顺序与解析批处理;macOS 和 Windows 上的真实 pwsh 套件证明持久 cwd/env、密钥清洗、UTF-8 输出、多行与 here-string 命令、大输出裁剪及退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -46,6 +46,7 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo - **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 - **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 - **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **手写光标位置响应。** 拒绝:响应必须反映 shell 已经发出的光标移动、换行折叠和控制序列。固定坐标会放大控制台重绘并可能耗尽有界输出;`@xterm/headless` 会维护这份协议状态,但不取代逐行输出投影。 - **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 - **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 - **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 @@ -62,4 +63,6 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**终端协议响应先于调用方输入。** headless 模拟器不保留 scrollback,也不贡献模型可见文本;它跟踪终端控制状态,并通过已挂载的进程管理提供方发出响应。这会增加受维护的 `@xterm/headless` 运行时依赖,并避免光标查询消费后续工具命令。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;若 marker 就绪持续不可用,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。模型后来重定义 `prompt` 函数会使命令就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 与 `@xterm/headless` 分别增加进程基座和终端后端的依赖评审。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml index 0c1ca6484d..6f7a67dd4d 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.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-11-repository-naming-contract-and-rename-ledger.md -2026-08-11-repository-naming-contract-and-rename-ledger.md: 895a202256504690b0451e6c09f3fc4ea8e7f4db -2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 1bf7bc9a44467dbaf099f2e99f3a8ee9634ae682 +2026-08-11-repository-naming-contract-and-rename-ledger.md: 2de88df5f1a5037d32daa9a8ee9cd1edfc60528a +2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 0cbbbee561358614da0b20e49c610fbee1d4e537 diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md index 895a202256..2de88df5f1 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md @@ -274,10 +274,14 @@ Keep MCP, Todo, and the Plan Mode package, key, events, and tool names. This dec | `E2BSandboxService` | `E2BRuntime` | The class creates, reuses, and disposes the E2B execution environment used by filesystem and subprocess adapters. It is broader than one sandbox handle and narrower than a generic owner. Keep `@deepseek-ai/dsh-e2b`, `ctx.e2b`, and the `e2b/` group. | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | The package is the Host plugin that serves the frontend assets. The prefix distinguishes it from frontend application code. | | `PluginInventoryService` | `PluginInventoryGateway` | The class is a Remote-only adapter from the live Loader tree to the `pluginInventory/list` RPC. It owns no same-process service, cache, history, or mutation path. `Gateway` states the role that exists. | -| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | The example demonstrates the runtime SDK over JSON-RPC. It belongs to the one SDK meaning. | +| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | The private package carries only the temporarily separate Python SDK runtime; the [single dsh launcher decision](2026-08-22-single-dsh-application-launcher.md) owns its application-boundary change. | +| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | The carrier is production packaging infrastructure for the Python SDK, not a demo bundle. | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The direct-config runnable example belongs specifically to the Python SDK exception. | +| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | The package is the ACP profile's application bundle, not a standalone demo bin. | +| Deploy-root manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | The manifest defines the private Python runtime dependency closure. The Python-visible executable basename remains fixed until its documented profile migration. | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | The application is the web frontend. Keep its physical `apps/web/` folder. | -Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution. +Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, `dsh-sdk-app`, `dsh-acp-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution. ### Client runtime and UI @@ -309,7 +313,7 @@ Keep atomic-write, brand, native-command, timeout utility, directory-picker, `ds | `ConversationService` | `ConversationController` | The object controls the active conversation state and user actions. | | `InputService` | `SessionInputResolver` | The interface resolves the input facade for one session scope. It is neither a global input registry nor an execution service. Keep `InputHub` as the concrete hub and `ctx.conversation.input` as the published face. | -Use `Ui`, not `UI`, inside PascalCase identifiers. Keep the remaining client package names unless this ledger names them. Keep the deprecated client connection and Host `ApiProxy` vocabulary for now; the API plane will replace them, and a rename would add churn to a surface scheduled for removal. +Use `Ui`, not `UI`, inside PascalCase identifiers. Keep the remaining client package names unless this ledger names them. Retain the deprecated client connection and Host `ApiProxy` vocabulary until the API plane removes those surfaces; renaming them earlier would add churn without establishing a lasting name. ## Explicit non-renames diff --git a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md index 1bf7bc9a44..0cbbbee561 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.zh.md @@ -274,10 +274,14 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui` | `E2BSandboxService` | `E2BRuntime` | 该类创建、复用和释放文件系统与子进程适配器所使用的 E2B 执行环境。它比单个沙箱句柄的职责更广,又比通用所有者更具体。保留 `@deepseek-ai/dsh-e2b`、`ctx.e2b` 和 `e2b/` 组。 | | `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | 该包是提供前端资源的 Host 插件。此前缀可将它与前端应用代码区分开。 | | `PluginInventoryService` | `PluginInventoryGateway` | 该类只负责把实时 Loader 树适配到 `pluginInventory/list` RPC。它不拥有同进程服务、缓存、历史或修改路径。`Gateway` 准确说明现有角色。 | -| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | 该示例演示通过 JSON-RPC 使用运行时 SDK,属于 SDK 的唯一含义。 | +| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | 该私有包只承载暂时独立的 Python SDK 运行时;其应用边界变更由[单一 dsh 启动器决策](2026-08-22-single-dsh-application-launcher.zh.md)负责。 | +| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | 该载体是 Python SDK 的生产打包基础设施,不是演示组合包。 | +| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 直读配置的可运行示例专属于 Python SDK 例外。 | +| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | 该包是 ACP profile 的应用组合包,不是独立 demo bin。 | +| 部署根 manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | 该 manifest 定义私有 Python 运行时依赖闭包。面向 Python 的可执行文件基本名称保持不变,直至完成已记录的 profile 迁移。 | | `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | 该应用是 Web 前端。保留其物理目录 `apps/web/`。 | -保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。 +保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、`dsh-sdk-app`、`dsh-acp-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。 ### 客户端运行时与 UI @@ -309,7 +313,7 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui` | `ConversationService` | `ConversationController` | 该对象控制当前对话状态和用户操作。 | | `InputService` | `SessionInputResolver` | 该接口为一个会话作用域解析输入外观。它既不是全局输入注册表,也不是执行服务。保留 `InputHub` 作为具体中枢,并保留 `ctx.conversation.input` 作为对外接口。 | -PascalCase 标识符内部使用 `Ui`,不要使用 `UI`。除非清单明确要求重命名,否则保留其余客户端包名。暂时保留已弃用的客户端连接和 Host `ApiProxy` 词汇;API 平面将替换它们,而在计划移除的表面上重命名只会增加改动量。 +PascalCase 标识符内部使用 `Ui`,不要使用 `UI`。除非清单明确要求重命名,否则保留其余客户端包名。在 API 平面移除相关表层之前,保留已弃用的客户端连接与 Host `ApiProxy` 词汇;提前重命名只会增加改动量,不会建立持久名称。 ## 明确保留的名称 diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml index 4ab07f7f07..9338e0cf6c 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.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-12-pi-ai-route-default-input-modalities.md -2026-08-12-pi-ai-route-default-input-modalities.md: efd20b2cd73979208bb777fa42536bc5b918e29e -2026-08-12-pi-ai-route-default-input-modalities.zh.md: 069a7916c8d4ffe738ff910a851e0a9cd1f66d0a +2026-08-12-pi-ai-route-default-input-modalities.md: eb03d5330a1283d439e16262325965cf2e7e8087 +2026-08-12-pi-ai-route-default-input-modalities.zh.md: dfbbd2ae6db7a78e82955db66fe506d3506209fd diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md index efd20b2cd7..eb03d5330a 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md @@ -18,17 +18,17 @@ The assumption was justified in the source as the adapter's real capability rath **The route value is a fallback, not an override — the catalog outranks it.** This is the `default*` ordering rather than `compat`'s, and the two are not interchangeable: `compat` shadows the catalog because a route-level protocol repoint invalidates the catalog's reasoning-dispatch facts wholesale, while a modality is a per-model property the catalog states accurately for the models it ships. Making the route value win would mean `defaultInput: [text]` silently strips images from every catalog vision model on the route — a footgun with no matching benefit, since narrowing one such model is what that model's own `input` is for. -**Undeclared means `[text]`, and that is the absence of a declaration rather than a guess at the endpoint.** Nothing can interrogate a gateway for its modalities — no OpenAI-compatible listing endpoint reports them — so the only honest floor is the modality every supported protocol certainly carries. This is where the modality fallback parts company with the capacity ones: 262,144 tokens is merely plausible and wrong in both directions (a gateway serving 8k overflows, one serving 1M is wasted), while text is safe in one direction. The two wrong answers do not cost the same either. Under-claiming refuses the image before it is attached, naming the model, and the remedy is one documented line. Over-claiming admits an image the provider then rejects mid-turn, *after* prompt admission has committed the message durably, so the session keeps re-sending a request that cannot succeed and model selection refuses a switch to any text-only model. A cheap refusal at the earliest resolvable point beats an expensive one at the latest. +**Undeclared means `[text]`, and that is the absence of a declaration rather than a guess at the endpoint.** Nothing can interrogate a gateway for its modalities because no OpenAI-compatible listing endpoint reports them. The only safe floor is the modality every supported protocol certainly carries. Under-claiming refuses the image before it is attached, names the model, and has a documented configuration remedy. Over-claiming admits and persists an image before the provider can reject it. Later requests to that same incorrectly declared route will encounter the image again, although the user can select a text-only model because request assembly projects durable images to placeholders. **An entry's empty list means the same as an absent one; the route's is refused.** `[]` describes a model that accepts nothing and could serve no request, so it states no answer and resolution continues past it. That reading is not cosmetic: the config schema materializes `[]` for an absent array, so treating it as "accepts nothing" would silently strip images from every catalog vision model a `models` list happens to name. The route value has nothing below it to answer instead, so its empty list is refused where it is written. The route's `models` list already resolves absent-and-empty the same way for the same reason. **No configuration surface edits `input`.** It joins `compat`, `reasoningEfforts`, `thinkingBudgets`, and `headers` as a settings-document field, and the model-list editor stays a hand-written form over id, name, and the two capacities. This costs nothing durable because that card was already built to carry fields it does not edit: its row patch spreads the stored row before applying changes, and adoption keeps an existing row over a rediscovered candidate, so a hand-written `input` survives both. -The DeepSeek chat-completions adapter is untouched. Its `['text']` is a fact about its serializer, not a missing declaration, and it keeps refusing before the send. +The direct DeepSeek adapter owns a separate exact-model catalog. Its supported vision entry declares image input, while its text models and unlisted pass-through ids remain text-only. ## Alternatives considered -- **An optimistic `[text, image]` default** — makes the motivating case work with zero configuration, and the web form writes no modality at all, so a conservative default leaves the remedy in a file a web-only user has no reason to open. Rejected on the severity of being wrong: a refused attachment is a speed bump with a documented fix, while a provider rejection poisons the session, presents as an unexplained repeating failure, and is escapable only by switching models or starting over. Documenting the remedy on the model-configuration page closes the discoverability gap; nothing closes the poisoned session. +- **An optimistic `[text, image]` default** — makes the motivating case work with zero configuration, and the web form writes no modality at all, so a conservative default leaves the remedy in the settings document. Rejected because a false positive persists an image before the provider refuses it and causes repeated failure on that route. Text-only request projection provides recovery but does not make the declaration true. - **A route value that overrides the catalog** (`compat`'s ordering: entry → route → catalog) — lets a deployment that repoints a catalog route at its own gateway declare "no vision here" once. Rejected because the same sentence then silently disables every catalog vision model on a route where someone wrote it by analogy with the capacity fields, and the legitimate case is served by that model's own `input`. An override would also have to be named `input` at the route, since calling it `default*` beside two genuine fallbacks would misdescribe it. - **No route field at all, only the entry one** — closest to upstream, which has no route-level concept. Rejected on the bulk case the product's own flow produces: "fetch available models" adopts thirty ids with no modality, and an all-vision gateway would need `input` hand-written on each. - **A route-level `defaultInput` with no entry field** — cannot mix modalities on one route or correct a single catalog model, leaving "split the provider across two route keys" as the only workaround, at the cost of a second permanent provider id and a duplicate entry in every model selector. @@ -42,7 +42,7 @@ A vision model on a custom provider costs one line, `input: [text, image]`, writ The image-admission gate keeps its meaning everywhere, because every modality it reads is now either recorded by the installed catalog or written by a person. Nothing claims a capability on a deployment's behalf. -A model that declares images its endpoint does not serve is not caught locally — the claim is not verified — and the resulting failure is expensive. Prompt admission commits the user message durably (`agent/inbox/spliced`) before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is to select a model that does serve images, fork before the image, or start a session. Making that failure non-destructive — rolling an unconsumed image message back out of the log when the send fails — is the change that would make an optimistic default reconsiderable, and is not attempted here. +A model that declares image input its endpoint does not serve is not caught locally because the claim is not verified. Prompt admission commits the user message durably before request construction, so the rejected image stays in the session log and later requests to that route can fail again. Recovery is to correct the declaration, select an image-capable route, or select a text-only route whose request projection replaces durable images with placeholders. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md index 069a7916c8..dfbbd2ae6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md @@ -18,17 +18,17 @@ Harness 把缺失的模态当作否定能力,并有三个准入点在构造任 **路由值是回退值而非覆盖值——catalog 的优先级更高。** 这采用的是 `default*` 的顺序而非 `compat` 的,两者不可互换:`compat` 之所以盖住 catalog,是因为路由级的协议改指会整体作废 catalog 关于推理分派的事实;而模态是按模型的属性,对 catalog 自己出货的那些模型,它记录得准确无误。让路由值获胜就意味着 `defaultInput: [text]` 会悄悄剥掉该路由上每一个 catalog 视觉模型的图片能力——一个没有对应收益的坑,因为收窄其中某个模型正是该模型自己的 `input` 要做的事。 -**未声明即 `[text]`,而这是「尚未声明」,不是对端点的猜测。** 没有任何环节能去询问网关的模态——没有任何 OpenAI 兼容的列表端点会报告它们——因此唯一诚实的底线是每个受支持协议都确定携带的那个模态。这也正是模态回退值与容量回退值分道扬镳之处:262,144 只是个说得过去的数字,且两个方向都会错(网关只给 8k 会溢出,给 1M 则被浪费),而 text 在一个方向上是安全的。两种猜错的代价同样并不对等。少声明会在图片被附加之前就拒绝并点名该模型,补救办法是一行有文档可依的配置。多声明会接纳一张图片、再由提供方在轮次中途拒绝——而此时 prompt 准入**早已**把消息持久化提交,于是会话会不断重发一个不可能成功的请求,且模型选择拒绝切换到任何纯文本模型。在最早可解析点付出一次廉价的拒绝,胜过在最晚点付出一次昂贵的。 +**未声明即 `[text]`,而这是「尚未声明」,不是对端点的猜测。** 没有任何环节能询问网关的模态,因为 OpenAI 兼容列表端点不会报告它们。安全的底线是每个受支持协议都确定携带的模态。少声明会在图片附加之前拒绝、点名模型,并给出有文档的配置补救方法。多声明会先接纳并持久化图片,再由提供方拒绝。之后对同一错误声明路由的请求还会再次遇到图片,但用户可以选择纯文本模型,因为请求组装会把持久图片投影为占位符。 **条目的空列表与缺省同义;路由的空列表则被拒绝。** `[]` 描述的是一个什么都不接受、无法服务任何请求的模型,因此不作答,解析继续往下走。这个读法不是修辞:配置 schema 会为缺省数组物化出 `[]`,把它当作“什么都不接受”,会悄悄剥掉 `models` 列表恰好点到的每一个 catalog 视觉模型的图片能力。而路由值下面没有可以代为作答的层级,因此它的空列表在写入处即被拒绝。路由的 `models` 列表出于同样的理由,早已用同一种方式解析缺省与空。 **没有任何配置界面编辑 `input`。** 它和 `compat`、`reasoningEfforts`、`thinkingBudgets`、`headers` 一样是 settings 文档字段,而模型列表编辑器仍是一张只覆盖 id、名称和两个容量的手写表单。这不会带来持久代价,因为那张卡片本来就是按“承载自己并不编辑的字段”建造的:它的行 patch 会先展开已存储的行再应用改动,而采纳候选时已有行优先于重新发现的候选,因此手写的 `input` 在两条路径上都能存活。 -DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其序列化器的事实,而不是一处缺失的声明,它继续在发送前拒绝。 +DeepSeek 直接适配器拥有独立的精确模型目录。支持视觉的条目声明图片输入,纯文本模型和未列出的透传 ID 保持纯文本。 ## 备选方案 -- **乐观的 `[text, image]` 默认值** —— 让触发本次变更的场景零配置即可工作;而且网页表单不会写入任何模态,因此保守默认值会把补救办法留在一个纯 Web 用户没有理由打开的文件里。被否决的理由是猜错时的严重程度:被拒绝的附件是一个有文档可依的减速带,而提供方拒绝会毒化整个会话、表现为一次无从解释的反复失败,且只能靠换模型或重开会话脱身。把补救办法写进配置模型页即可补上可发现性的缺口;而毒化的会话没有任何东西能补。 +- **乐观的 `[text, image]` 默认值** —— 让触发场景无需配置即可工作,而网页表单不会写入模态,因此保守默认值会把补救方法留在 settings 文档里。否决原因是错误的肯定声明会在提供方拒绝之前持久化图片,并让该路由重复失败。纯文本请求投影提供了恢复方法,但不能让错误声明变成事实。 - **让路由值盖住 catalog**(`compat` 的顺序:条目 → 路由 → catalog)—— 可以让把 catalog 路由改指到自家网关的部署,一句话声明「这里没有视觉能力」。被否决是因为同一句话也会在有人照着容量字段类比写下它的路由上,悄悄禁用每一个 catalog 视觉模型;而那个正当场景由该模型自己的 `input` 承担。覆盖值还必须在路由级改名叫 `input`,因为在两个货真价实的回退值旁边把它叫作 `default*` 是名不副实。 - **完全不要路由字段,只要条目字段** —— 最贴近上游(上游没有路由级概念)。被否决的理由是产品自身流程会产生的批量场景:「获取可用模型」一次采纳三十个不带模态的 id,全是视觉模型的网关就得逐个手写 `input`。 - **只要路由级 `defaultInput`,不要条目字段** —— 无法在一条路由上混合模态,也无法修正单个 catalog 模型,唯一的变通办法只剩「把该提供方拆成两个路由键」,代价是多一个永久的 provider id 和每个模型选择器里的一项重复。 @@ -42,7 +42,7 @@ DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其 图片准入门禁在各处都保住了自己的意义,因为它读到的每一个模态,如今要么由已安装 catalog 记录,要么由人写下。没有任何环节会替部署宣称一项能力。 -声明了端点并不提供的图片能力的模型不会在本地被拦下——该断言不经验证——而由此产生的失败代价高昂。prompt 准入在构造请求之前就把用户消息持久化提交(`agent/inbox/spliced`),因此被拒绝的图片会留在会话日志里:该模型会不断重发它,而模型选择拒绝切换到任何纯文本模型。恢复途径是选择一个确实提供图片能力的模型、fork 到图片之前,或者开启新会话。让这次失败不具破坏性——发送失败时把尚未消费的图片消息从日志中回滚出去——才是能让乐观默认值重新可考虑的那项改动,本次未做尝试。 +声明了端点并不提供的图片能力时,本地无法发现该错误,因为声明不会被远端验证。prompt 准入会在请求构造前持久化用户消息,因此被拒绝的图片留在会话日志中,之后对该路由的请求可能再次失败。恢复方法是修正声明、选择支持图片的路由,或选择由请求投影把持久图片替换为占位符的纯文本路由。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index c3d26e40a4..8ae275a581 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.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-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: 722e6cfbe890418e8305f89790e76976027d7775 -2026-08-12-plugin-owned-settings-surface.zh.md: ddb1d70aed5427e58720a9be558a83a5e034c335 +2026-08-12-plugin-owned-settings-surface.md: daed91b8ac4ce0acb81e19908ec08c9f3f7ac21e +2026-08-12-plugin-owned-settings-surface.zh.md: ba39b84faaf90be413ad8d7b8327c67b2fa27cea diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index 722e6cfbe8..daed91b8ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -52,7 +52,7 @@ So the exposure this change actually adds, in this repository, is one namespace: ## Consequences -A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`. That is stable for the cards this package registers, which install from one generator, and **not** stable across plugins: apply order between packages is unconstrained (`packages/client/AGENTS.md`), so several external cards can still reorder between boots. Ordering them needs an explicit key the section can sort on, which the keyed registration does not carry today. +A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`. That is stable for the cards this package registers, which install from one generator, and **not** stable across plugins: apply order between packages is unconstrained (`packages/client/AGENTS.md`), so several external cards can still reorder between boots. Ordering them needs an explicit key the section can sort on, which the keyed registration does not carry. Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index ddb1d70aed..ba39b84faa 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -52,7 +52,7 @@ Status: implemented ## Consequences -在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`。对本包注册的这几张卡它是稳定的——它们从同一个 generator 安装;对**跨插件**的卡片它并不稳定:包与包之间的 apply 顺序是无约束的(`packages/client/AGENTS.md`),因此多个外部卡片仍可能在不同次启动之间重排。要为它们定序,需要一个 section 可排序的显式键,而 keyed 注册今天并不携带。 +在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`。对本包注册的这几张卡它是稳定的——它们从同一个 generator 安装;对**跨插件**的卡片它并不稳定:包与包之间的 apply 顺序是无约束的(`packages/client/AGENTS.md`),因此多个外部卡片仍可能在不同次启动之间重排。要为它们定序,需要一个 section 可排序的显式键,而 keyed 注册并不携带。 以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml index fb9c5c6442..5dba989cb5 100644 --- a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.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-13-credential-records-and-authorization-flows.md -2026-08-13-credential-records-and-authorization-flows.md: a52d85854c8c660b4965d07a13a2dbfefbac5723 -2026-08-13-credential-records-and-authorization-flows.zh.md: 7ba73f133f240e41bf2aecff32a45115b9bed531 +2026-08-13-credential-records-and-authorization-flows.md: 23d3dda6e90809817b74dce98f380334bbbc0f4d +2026-08-13-credential-records-and-authorization-flows.zh.md: c6ec580b4ed73f8d0a9d6f9922b2b6b220f2c428 diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md index a52d85854c..23d3dda6e9 100644 --- a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md @@ -53,7 +53,7 @@ Withdrawal settles an attempt whether or not its flow reacts to the signal. A fl `.credentials.yaml` gains a version and two sections. A boot upgrades the recognized pre-release flat layout in place — an all-string flat mapping nests verbatim under `refs:` under the writer lock — because a key stored through the Models page by an earlier internal build must survive the layout change without a hand edit and without its model requests failing. Any flat shape the recognizer cannot prove it understands keeps the by-name refusal with the hand migration stated in the message; the parser itself still reads exactly one layout, and the migration step retires with the pre-release stance at the first tagged release. Every fixture in the repo that wrote the flat document was rewritten; the llm suites' fixtures were missed by the record change itself and fixed here. -`openai-codex` returns to the provider picker and to the Models page directory. Signing in is offered for every installed provider that ships a login, which today is all 38 — 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login. +`openai-codex` returns to the provider picker and to the Models page directory. All 38 installed providers offer sign-in: 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login. What this does not yet include is the surface: the wire contract that carries notices and prompts to the browser, and the Models-page control that starts a login. Until that lands, the flows are reachable only in-process, and a deployment still configures a key by typing it into the settings form. @@ -65,4 +65,4 @@ The seam's suite pins the lifecycle it owns: single-flight refusal and release, `llm-pi-ai` covers the three translations against a real `$DSH_HOME` document — an api-key credential field by field, an OAuth credential verbatim including its refresh half, a foreign plugin's record skipped by scope, and the write refusal without a credentials service — plus every `AuthEvent` and `AuthPrompt` member restated, with `Models.login()` mocked at the collection boundary since a real one opens a browser. Two real-composition tests boot the plugin with and without the authorization seam. -The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the whole assembled-application difference this change makes today, because the Models page has no login control yet to record. +The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the only assembled-application difference this decision records, because the Models page has no login control yet to record. diff --git a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md index 7ba73f133f..c6ec580b4e 100644 --- a/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md @@ -53,7 +53,7 @@ seam 的边缘与写入路径同一纪律。prompt 被拒是结果而非故障 `.credentials.yaml` 增加了版本与两个分区。启动时会把能精确识别的发布前扁平布局原地升级——全字符串的扁平 mapping 在写锁下逐字下沉到 `refs:` 之下——因为早期内测构建经模型页面存下的密钥必须在布局变更后继续可用,不能要求手工编辑,也不能让模型请求失败。识别器无法证明自己理解的扁平形态仍被指名拒绝,迁移办法写在报错信息里;解析器本身始终只读一种布局,迁移步骤将随发布前立场在首个正式版本时移除。仓库中所有写扁平文档的 fixture 都已改写;llm 各套件的 fixture 被记录改动本身漏掉了,在此补上。 -`openai-codex` 回到提供方选择器与 Models 页目录。凡是自带登录的已安装提供方都会得到登录入口,而今天这是全部 38 个——31 个经 pi-ai 自己的提示收取密钥,6 个在此之外还提供订阅登录,Codex 只提供订阅登录。 +`openai-codex` 回到提供方选择器与 Models 页目录。全部 38 个已安装提供方都提供登录入口:31 个经 pi-ai 自己的提示收取密钥,6 个在此之外还提供订阅登录,Codex 只提供订阅登录。 尚未包含的是界面:把 notice 与 prompt 送到浏览器的 wire 契约,以及 Models 页上发起登录的控件。在那之前,flow 只能在进程内触达,部署方仍然通过在设置表单里输入密钥来配置。 @@ -65,4 +65,4 @@ seam 自己的套件钉住它拥有的生命周期:单飞的拒绝与释放、 `llm-pi-ai` 针对一份真实的 `$DSH_HOME` 文档覆盖三处翻译——逐字段的 api-key 凭据、连 refresh 半边一起原样保存的 OAuth 凭据、按 scope 跳过的他插件记录,以及没有凭据服务时的写入拒绝——外加每一个 `AuthEvent` 与 `AuthPrompt` 成员的重述;`Models.login()` 在集合边界处被 mock,因为真实登录会打开浏览器。两个真实组合测试分别在挂载与不挂载授权 seam 的情况下启动插件。 -`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好收回了被扣留时失去的那一行 `openai-codex` 选项——这是本次改动今天在装配后的应用上造成的全部差异,因为 Models 页还没有可录制的登录控件。 +`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好收回了被扣留时失去的那一行 `openai-codex` 选项——这是本决策记录的唯一装配后应用差异,因为 Models 页还没有可录制的登录控件。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml new file mode 100644 index 0000000000..b0b466cfa3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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/implemented/architecture/2026-08-18-session-history-and-event-transport.md +2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd +2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md new file mode 100644 index 0000000000..206d3d13d1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -0,0 +1,369 @@ +# Agent Note: Session history, control state, and Remote event transport + +Status: implemented + +English | [中文](2026-08-18-session-history-and-event-transport.zh.md) + +## Problem + +The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay. + +These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. + +Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects. + +Commands such as prompt, create, fork, and model selection do need to create or resume an Agent according to their own semantics. Activation authority must belong to each Remote method, not be decided implicitly by the carrier, parameter types, or a shared lookup. + +The legacy API Proxy all-Session mux, `HostFrame`, and Workspace notifications encode domain data, baselines, errors, and connection lifecycle in one handwritten protocol. Each additional state duplicates frame declarations, a Client bridge, reconnect handling, and cleanup logic, while API Proxy cannot return to owning only business methods that have not yet migrated. + +Host-to-Client Cordis events also have two invocation modes. Ordinary notifications only need broadcast delivery; Agent-scoped waterfalls such as Approval and Question must let a Client claim, delegate through `next()`, return a result, or reject while preserving one Host invocation identity across multiple Clients, disconnects, and cancellation. + +These requirements need one general transport lifecycle without making Gateway understand Session, Workspace, Approval, or Question business data. + +## Decision + +API Gateway owns Remote transport, stream lifecycles, and Remote Event coordination. Session Controller and Workspace Controller own their Host APIs, wire types, and Client domain adapters. Client Runtime only composes and consumes these objects; it does not implement another carrier state machine. + +Current ownership is: + +```text +[client/connection] +|-- Host description +|-- Connection generation +`-- unary RPC transport + +[api/gateway/client] +|-- RemoteStream +|-- RemoteSnapshotStream +|-- RemoteJournalStream +`-- ctx.remote.$on + $events pump + +[api/session-controller] +|-- ctx.remote.session unary commands +|-- session.control snapshot stream +|-- session.page + session.follow journal +`-- Session Client adapters + +[api/workspace-controller] +|-- ctx.remote.workspace unary commands +|-- workspace.follow snapshot stream +`-- Workspace Client model and adapter + +[api/remotes] +`-- application Remote Event allowlist and Host Cordis source + +[client/runtime] +`-- compose Session and Workspace domain state for consumers +``` + +API Proxy owns neither the Session or Workspace Remote namespace nor the Host downlink event carrier. `/api/events.host`, `HostFrame`, `stream/error`, `ServerRequest`, and their WebSocket/SSE branches do not participate in this data path. + +### Connection generation and physical connections + +The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. + +After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. + +In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. + +The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners. + +The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments. + +`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. + +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff. + +Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. + +Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. + +### General Remote stream model + +Gateway Client provides three React-independent, single-consumer lifecycle objects: + +```text +RemoteStream +|-- RemoteSnapshotStream +`-- RemoteJournalStream +``` + +Domain Controllers use them through composition or thin adapters; Session and Workspace do not inherit a common Controller base class that knows domain frames. + +#### `RemoteStream` + +`ctx.remote.$stream(options)` returns a `RemoteStream` responsible for reopening, cancellation, and disposal of one logical stream across physical generations. + +Each item carries a monotonic generation, that generation's `AbortSignal`, and `accept()`. A domain consumer calls `accept()` only after validating the opening cursor or baseline. + +Only `RemoteStreamCarrierError` permits retry. When the Host remains available, one independent reopen is allowed; otherwise the stream waits for a new Connection generation. Business errors, protocol errors, and opening failures terminate immediately. + +`restart()` replaces only the current physical generation and preserves the logical stream. `dispose()` permanently ends the logical stream, pending retry, and iterator, then waits for quiescence. + +`RemoteStream` does not understand baselines, deltas, pages, cursors, sequence numbers, or any domain frame. + +#### `RemoteSnapshotStream` + +`RemoteSnapshotStream` requires each generation to start with exactly one complete snapshot, followed only by deltas. + +An update before the snapshot or a second snapshot in the same generation is a terminal protocol error. + +The generation is accepted only after its snapshot has been applied successfully. The previously published state remains readable while the carrier reconnects, and the new generation's snapshot replaces the old mirror atomically. + +The domain adapter supplies frame discrimination, snapshot replacement, a delta reducer, carrier state, and a terminal failure sink. The general layer parses no Session or Workspace fields. + +Session control and Workspace state each use an independent `RemoteSnapshotStream`. + +#### `RemoteJournalStream` + +`RemoteJournalStream` combines one live follow with a page method in the same namespace. It applies to an append-only journal with a stable order, paginated history, and a live tail. + +Initial opening establishes follow and obtains its opening cursor before reading the initial page. Live entries produced while the page request is pending already enter the follow queue, closing the race between reading history and subscribing afterward. + +The general layer removes overlap between the page and queued entries by cursor, verifies continuity, and publishes one complete window after the page covers the opening cursor. + +Contiguous live entries publish `append`; older history pages publish `prepend`. Reconnect, cursor jumps, or continuity that cannot be proven trigger a tail-page repair. + +The old window remains readable during repair. The page and live entries accumulated during that read form a continuous window and publish one `replace`, never exposing a half-repaired state. + +If a page request is canceled with its physical carrier generation, the journal waits for the next generation's opening cursor and rereads the page at that cursor. This cancellation does not leak to the domain object as a terminal page failure. + +`RemoteJournalStream` owns the opening cursor, resume cursor, pagination, reconnect catch-up, overlap removal, and gap repair. A domain Session object does not copy these state machines. + +### Session Controller + +`packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. + +It owns Session list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. + +The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. + +Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver. + +#### Activation policy + +Session Remote methods pass `SessionId` or `SessionAddress`; parameter types do not trigger a general Typert Session lookup. + +Each method explicitly selects a cold inspection, live-only lookup, or resume-capable resolution: + +| Operation | Source or result without a live Agent | Activation rule | +|---|---|---| +| `session.list`, `search` | persistence, projection cache, or cold log | Never resumes an Agent | +| `session.page(address)` | attached Session or persistence log | Never resumes an Agent | +| `session.follow(address)` | cold-read current cursor, then wait for future appends | Neither opening nor waiting resumes an Agent | +| `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | +| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | +| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state | +| `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | +| `create` and fork target | new Session/Agent | The user command supplies creation authority | + +Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup. + +#### Session journal + +`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages. + +The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark. + +Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone. + +`session.follow` installs `session/event` and `session/created` listeners before checking an attached Session or persistence, then reads the current cursor. + +The first follow response is `{ type: 'opened', cursor }`. A generation with `afterSeq` first replays the missing suffix from the authoritative log, then emits commits buffered during the read in sequence order. + +A cold Session can open history immediately and keep follow waiting. Future events appear only after another explicit command resumes the Agent. + +Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer first obtains opening cursor `C`, then calls `session.page({ throughSeq: C })`; entries `C + 1...` received during the read remain in the follow queue, and the page must cover exactly through `C` before the layer merges and publishes a continuous sequence. + +```text +ctx.remote.session.follow(address, afterSeq?) --------| + |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) + |-- prepend(history) + `-- append(live entry) +``` + +Each Client Session owns only one current `events: SessionEventStream | undefined`. The read-only `SessionEventSource` gives the materialized event window to Conversation consumers. + +A Session's `openGeneration` only prevents an asynchronous result retired by resync, address replacement, or disposal from writing into current state. It does not participate in transport retry. + +A terminal failure from the initial page, repair page, or follow enters the current Session's `openError`. A stale business epoch or stale stream cannot overwrite newer state. + +#### Session live control + +`session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript. + +Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents. + +Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline. + +The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests. + +The projection baseline and a tail page's log cut are produced independently. The Client always retains the value with the higher sequence number. Subscribing to live projection does not start an Agent merely to obtain a value. + +Session added, removed, activity, running status, and Agent error without a turn position do not enter the stateful control stream; they are `ctx.remote.$on` notifications that are either repairable from a list baseline or need no replay. + +Session-list `updatedAt` is `max(header.createdAt, sessionListMetadata.lastPromptAt)`. Only a user-originated `user/message` updates `lastPromptAt`; it can be recovered from a cold projection and does not depend on whether a browser follows that Session. + +### Workspace Controller + +`packages/api/workspace-controller` provides Host `ctx.workspaceController` and the generated `ctx.remote.workspace` namespace. + +It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, and `follow`. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping. + +`WorkspaceFeed` synchronously observes storage `domain/changed`, and each follow generation emits a complete baseline before `upsert`, `remove`, `order`, and `archived` deltas. + +A complete `order` frame is authoritative for Workspace ordering. It avoids having the Client infer display order from upsert arrival order and converges after a reconnect baseline. + +`createWorkspaceStateStream()` assembles `workspace.follow` as a `RemoteSnapshotStream`. Client Runtime only starts and owns that stream. + +`ClientWorkspaceModel` lives on Workspace Controller's Client face. It owns baseline/increment parsing, the materialized list, the archived set, command-result echo, and merge rules for races between unary and stream arrivals. + +A successful unary command can update the local model immediately; a later stream commit still corrects state with the Host projection and complete order. Deleted Workspace ids are recorded so a delayed result cannot reinsert them. + +```text +ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream + |-- replace(baseline) + |-- upsert/remove(view) + |-- replace(order) + `-- replace(archived ids) +``` + +Workspace Remote methods, state feed, and Client data model do not pass through API Proxy or depend on `host/workspace-*` notifications. + +### Remote Event + +Remote Event reuses owner packages' Cordis `Events` declarations. The original Host event is the sole business signature, and Client `ctx.remote.$on(event, listener)` derives its parameters, waterfall result, and `next()` from that declaration. + +The allowlist in `packages/api/remotes` is the sole source of application selection. Each entry explicitly marks `emit` or `waterfall`; this mode determines Host listening, the legal Client key set, and the wire frame type together. + +The system declares no `RemoteInvocationMap`, requires no second Client `@Remote`, and does not infer invocation mode by checking whether the final runtime argument is a function. + +Remote Event downlink frames form an explicit discriminated union: + +```text +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } +``` + +Both WebSocket JSON and in-process carrier entry points start from `unknown` and validate the discriminant plus exact fields. Dispatch after validation accepts only the typed union. TypeScript static types do not replace wire validation. + +Ordinary `emit` arguments must be lossless JSON. The Client calls `parallel()` on a Cordis key private to each Remote instance, preserving registration order, calling-fiber ownership, and listener-error isolation. + +The private key prevents Host events and same-named Client-local Cordis events from triggering one another. Client Remote maintains neither its own subscription registry nor a handwritten listener chain. + +Returning waterfalls currently support Agent scope only. The event signature must contain one request with a direct `agent` field followed by a `next()` returning the same result type, and the whole event returns a Promise. + +The Host projects only top-level `agent` and `signal` fields from the request: `agent` becomes top-level `agentId` in the frame, `signal` becomes the delivery lifetime, and all remaining fields must be lossless JSON as a whole. + +The Client synchronously resolves or materializes an Agent Context from `agentId`, restores the current delivery signal into the request's direct `signal` field, and invokes Cordis `waterfall()` on the target Context's private key. Before the first successful Session-list baseline, the Session-backed adapter lets transport materialize a scope; after that baseline, the list lifecycle owns scope liveness. + +The system does not scan arbitrarily deep objects, transmit path arrays or placeholders, deep-clone/restore Context and AbortSignal, or wait for a future Agent Context. + +When no Client adapter is registered, its resolver returns no Context, or resolution throws, that Client immediately returns `next`. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery. + +Gateway Host retains `eventId`, the Host continuation, and delivered Client generations for every unfinished waterfall. A new Client generation receives a replay of the same pending event. + +Each generation's queue guarantees one delivery, so the Client stores no `seen` set. `clientId + eventId` binds a result to the current generation; a reply from an old connection cannot complete delivery on a new one. + +When several Clients receive a waterfall, the first result or rejection completes the Host invocation and sends `cancel` to the other Clients. Gateway continues the original Cordis chain only after every delivered Client returns `next`. + +Host caller-signal cancellation, Agent Context disposal, Client-generation completion, and losing-Client cancellation all terminate their corresponding waits. + +The Client returns `next`, result, or rejection through the existing HTTP unary RPC `$events/result`; downlink events continue to share the Remote WebSocket mux, with no duplex WebSocket for responses. + +Gateway only verifies that a waterfall return value has a lossless JSON representation; it does not interpret business fields. Semantics such as whether a Question answer belongs to an offered option remain owned by the requester or UI domain and are not revalidated by transport. + +When `UserQuestionService` observes that the caller's `AbortSignal` was canceled during a request and the provider threw an ordinary error, it normalizes that failure to `UserQuestionError` with `ASK_ABORTED` while retaining the original error as `cause`. A domain error already supplied by the provider preserves its identity. + +A failure of `$events/result` fails the current Connection generation. Host withdraws that Client's delivery with the generation, the pending event is replayed in the next generation, and Client maintains no second result-retry queue. + +Ordinary `$on` notifications are not replayed after disconnect. State whose correctness depends on recovery must have a query, cursor, or opening baseline and cannot rely on eventual Remote Event delivery. + +An event is not replayed when its Client listener registers after arrival. HMR has no dedicated redelivery semantics. + +### API Proxy's remaining boundary + +Session Controller and Workspace Controller provide generated Remote namespaces directly; API Remotes and API Gateway provide Host-to-Client events directly. + +Client Connection maintains only Host generation, description, and generic RPC. It does not parse domain frames. + +Client Runtime only receives domain changes produced by Controller adapters. It recognizes no `HostFrame`, `session/subscribed`, `session/event` mux frame, or `host/workspace-*` frame. + +API Proxy carries only independent business APIs it owns. Session, Workspace, Remote Event, and Connection generation do not depend on it. + +## Alternatives considered + +**Resume an Agent whenever any Session stream opens.** Viewing history, reading a title, reconnecting a tab, or observing background state would gain execution side effects, and multiple browsers could trigger duplicate resumes. Cold logs and projections already have persistence sources. + +**Permit `session.follow` only for live Agents.** The first transcript render would have to resume an Agent or reintroduce the race between unary history and live subscription. Following by identity before a cold read covers both history and future explicit activation. + +**Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently. + +**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. + +**Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics. + +**Declare a separate Client invocation map for Remote Event.** A second map or Client `@Remote` would copy owner Cordis event signatures and create a drift point. Deriving `$on` listeners and results from the same `Events` declaration preserves equivalence by construction. + +**Project Agent scope through arbitrary object depth.** Recursive Context and AbortSignal scans need path, placeholder, clone, and restore protocols and turn incidental object structure into a wire promise. Top-level `agent` and `signal` cover current waterfalls. + +**Wait for a Client Agent Context or adapter before dispatching.** Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can synchronously resolve or materialize its target. Returning `next` when the resolver cannot provide a target immediately preserves Cordis waterfall semantics. + +**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. + +**Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. + +**Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. + +## Verification + +Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. + +Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. + +`RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence. + +`RemoteSnapshotStream` tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement. + +`RemoteJournalStream` tests pin follow-before-page, opening-overlap removal, contiguous append, historical prepend, reconnect catch-up, gap repair, and one atomic replacement. + +Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection. + +Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark. + +Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry. + +Workspace Host tests pin baseline-first delivery, upsert/remove, authoritative order, archived set, and follower disposal. + +Workspace Client tests pin snapshot replacement, unary/stream races, no resurrection after delete, stable ordering, and terminal failure. + +Remote Event type tests reject unselected events, non-void unscoped events, non-Agent-scoped waterfalls, and modes that disagree with signatures. + +Remote Event Host tests pin listener-before-ready, payload validation, pending replay, first result across multiple Clients, all-next delegation, rejection, Host cancellation, Context release, and losing-Client cancellation. + +Remote Event Client tests pin instance-private keys, Cordis registration order, Agent Context resolution, `next`, result, rejection, cancellation, rejection of stale-generation replies, and Connection-generation failure when `$events/result` fails. User Question tests pin normalization of in-progress signal cancellation and preservation of its cause. + +Missing, duplicate, and withdrawn sources; non-ready first items; unknown discriminants; extra fields; and non-JSON values all fail loudly at their respective wire entries. + +Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier and Client Runtime contains no corresponding bridge. + +## Consequences + +The browser can read and follow a durable Session while its Agent is stopped. Observation does not implicitly resume execution; only explicitly authorized Session commands create or resume Agents according to their own rules. + +Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another. + +Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. + +Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. + +The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. + +Remote waterfalls preserve first claim across multiple Clients, continuation of the Host chain after every Client calls `next`, reconnect replay of pending calls, and end-to-end cancellation. The current protocol supports only top-level Agent scope and lossless-JSON requests and results. + +This decision extends the allowlist and single Cordis-signature design from [Remote event delivery](2026-08-10-remote-event-delivery.md): ordinary notifications use `emit`, while Agent-scoped async waterfalls use the same `ctx.remote.$on` surface with explicit `waterfall` mode. It creates no second invocation map. + +This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md new file mode 100644 index 0000000000..3d0b5d4ab7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -0,0 +1,369 @@ +# Agent Note: 会话历史、控制状态与 Remote 事件传输 + +Status: implemented + +[English](2026-08-18-session-history-and-event-transport.md) | 中文 + +## 问题 + +浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。 + +这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 + +观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。 + +prompt、create、fork、模型选择等命令又确实需要按各自语义创建或恢复 Agent。激活权限必须属于具体 Remote 方法,而不能由 carrier、参数类型或共享 lookup 暗中决定。 + +旧 API Proxy 的全 Session mux、`HostFrame` 与 Workspace 通知把领域数据、baseline、错误和连接生命周期编码进同一手写协议。每增加一种状态都要复制帧定义、Client bridge、重连和清理逻辑,API Proxy 也无法退回只承接尚未迁移的业务方法。 + +Host 向 Client 的 Cordis 事件还有两种调用语义。普通通知只需要广播;Approval 与 Question 一类 Agent-scoped waterfall 必须允许 Client claim、调用 `next()` 委托、返回结果或拒绝,并在多 Client、断线和取消下保持一次 Host 调用的身份。 + +这些需求需要一个通用 transport 生命周期,但不能让 Gateway 理解 Session、Workspace、Approval 或 Question 的业务数据。 + +## 决定 + +API Gateway 拥有 Remote transport、stream 生命周期和 Remote Event 协调;Session Controller 与 Workspace Controller 拥有各自的 Host API、wire 类型和 Client 领域 adapter;Client Runtime 只装配并消费这些对象,不再实现另一套 carrier 状态机。 + +当前所有权如下: + +```text +[client/connection] +|-- Host description +|-- Connection generation +`-- unary RPC transport + +[api/gateway/client] +|-- RemoteStream +|-- RemoteSnapshotStream +|-- RemoteJournalStream +`-- ctx.remote.$on + $events pump + +[api/session-controller] +|-- ctx.remote.session unary commands +|-- session.control snapshot stream +|-- session.page + session.follow journal +`-- Session Client adapters + +[api/workspace-controller] +|-- ctx.remote.workspace unary commands +|-- workspace.follow snapshot stream +`-- Workspace Client model and adapter + +[api/remotes] +`-- application Remote Event allowlist and Host Cordis source + +[client/runtime] +`-- compose Session and Workspace domain state for consumers +``` + +API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 下行事件 carrier。`/api/events.host`、`HostFrame`、`stream/error`、`ServerRequest` 及其 WebSocket/SSE 分支不参与这条数据链路。 + +### Connection generation 与物理连接 + +浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 + +首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 + +进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 + +Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 + +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 + +`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 + +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 + +Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 + +插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 + +### 通用 Remote stream 模型 + +Gateway Client 提供三个不依赖 React、只允许一个 consumer 的生命周期对象: + +```text +RemoteStream +|-- RemoteSnapshotStream +`-- RemoteJournalStream +``` + +领域 Controller 通过组合或薄 adapter 使用它们;Session 与 Workspace 不继承一个知道领域帧的共同 Controller 基类。 + +#### `RemoteStream` + +`ctx.remote.$stream(options)` 返回 `RemoteStream`,负责一个 logical stream 跨物理 generation 的重开、取消和 dispose。 + +每个 item 携带单调 generation、该 generation 的 `AbortSignal` 与 `accept()`。领域 consumer 只有在验证 opening cursor 或 baseline 后才调用 `accept()`。 + +只有 `RemoteStreamCarrierError` 可触发重试。Host 仍可用时允许一次独立重开;否则等待新的 Connection generation。业务错误、协议错误和 opening 失败直接终止。 + +`restart()` 只淘汰当前物理 generation,保留 logical stream;`dispose()` 永久结束 logical stream、pending retry 与 iterator,并等待 quiescence。 + +`RemoteStream` 不理解 baseline、delta、page、cursor、seq 或任何领域 frame。 + +#### `RemoteSnapshotStream` + +`RemoteSnapshotStream` 要求每个 generation 恰好以一份完整 snapshot 开始,之后只能出现 delta。 + +update 早于 snapshot 或同 generation 出现第二份 snapshot 都是 terminal protocol error。 + +snapshot 成功应用后才接受该 generation。carrier 重连期间保留上一份已发布状态,新 generation 的 snapshot 一次性替换旧镜像。 + +领域 adapter 提供 frame 判别、snapshot replacement、delta reducer、carrier 状态和 terminal failure sink;通用层不解析 Session 或 Workspace 字段。 + +Session control 与 Workspace state 各使用一个独立的 `RemoteSnapshotStream`。 + +#### `RemoteJournalStream` + +`RemoteJournalStream` 组合一个 live follow 与同 namespace 的 page 方法,适用于有稳定顺序、可分页历史和 live tail 的 append-only journal。 + +首次打开先建立 follow 并取得 opening cursor,再读取 initial page。page 请求期间产生的 live entries 已进入 follow 队列,因此不会落在“先读历史、后订阅”的竞态窗口中。 + +通用层按 cursor 去除 page 与 queued entries 的重叠,验证连续性,并在 page 覆盖 opening cursor 后发布一份完整 window。 + +连续 live entry 发布 `append`,更早的历史页发布 `prepend`。重连、cursor 跳跃或无法证明连续性时触发 tail page repair。 + +repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼成连续窗口后只发布一次 `replace`,不会把半修复状态暴露给消费者。 + +若 page 请求随物理 carrier generation 一起取消,journal 等待下一 generation 的 opening cursor,再以新 cursor 重读 page;该取消不会作为 terminal page failure 泄漏给领域对象。 + +`RemoteJournalStream` 拥有 opening cursor、resume cursor、分页、重连 catch-up、重叠去重和 gap repair。领域 Session 对象不复制这些状态机。 + +### Session Controller + +`packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 + +它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。 + +包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 + +其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。 + +#### 激活策略 + +Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类型触发通用 Typert Session lookup。 + +每个方法显式选择冷检查、live-only 查找或允许 resume 的解析方式: + +| 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 | +|---|---|---| +| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent | +| `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | +| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent | +| `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | +| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | +| `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | +| `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 | + +读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 + +#### Session 日志 + +`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 + +tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 + +普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 + +`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。 + +首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit。 + +冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现。 + +Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布。 + +```text +ctx.remote.session.follow(address, afterSeq?) --------| + |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) + |-- prepend(history) + `-- append(live entry) +``` + +每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。 + +Session 的 `openGeneration` 只阻止被 resync、地址替换或 dispose 淘汰的异步结果写回;它不参与 transport retry。 + +initial page、repair page 或 follow 的 terminal failure 进入当前 Session 的 `openError`。旧业务 epoch 或旧 stream 的失败不能覆盖新状态。 + +#### Session live control + +`session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。 + +每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。 + +queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。 + +原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。 + +projection baseline 与 tail page 的日志切点独立产生,Client 总是保留较高 seq 的值。订阅 live projection 不会为取得值而启动 Agent。 + +Session added、removed、activity、running status 与无 turn 位置的 Agent error 不进入 stateful control stream;它们是可由列表 baseline 修复或无需重放的 `ctx.remote.$on` 通知。 + +Session 列表的 `updatedAt` 取 `max(header.createdAt, sessionListMetadata.lastPromptAt)`。`lastPromptAt` 只由用户来源的 `user/message` 更新,可从冷 projection 恢复,不依赖浏览器是否正在跟随该 Session。 + +### Workspace Controller + +`packages/api/workspace-controller` 提供 Host `ctx.workspaceController` 与生成的 `ctx.remote.workspace` namespace。 + +它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。 + +`WorkspaceFeed` 同步观察 storage `domain/changed`,并为每个 follow generation 先发送完整 baseline,再发送 `upsert`、`remove`、`order` 与 `archived` 增量。 + +完整 `order` frame 是 Workspace 排序的权威值。它避免 Client 根据 upsert 到达顺序猜测展示顺序,也能在重连 baseline 后收敛。 + +`createWorkspaceStateStream()` 把 `workspace.follow` 装配为 `RemoteSnapshotStream`。Client Runtime 只负责启动和持有该 stream。 + +`ClientWorkspaceModel` 位于 Workspace Controller 的 Client 面,拥有 baseline/increment 解析、已物化列表、归档集合、命令结果回显及 unary 与 stream 到达竞态的合并规则。 + +成功的 unary 命令可以立即更新本地模型;后到的 stream commit 仍以 Host projection 与完整 order 校正状态。已删除 Workspace 的 id 被记录,延迟结果不能把它重新插回列表。 + +```text +ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream + |-- replace(baseline) + |-- upsert/remove(view) + |-- replace(order) + `-- replace(archived ids) +``` + +Workspace Remote 方法、状态 feed 和 Client 数据模型均不经过 API Proxy,也不依赖 `host/workspace-*` 通知。 + +### Remote Event + +Remote Event 复用 owner 包的 Cordis `Events` 声明。Host 原事件是唯一业务签名,Client `ctx.remote.$on(event, listener)` 从同一声明推导参数、waterfall 结果与 `next()`。 + +`packages/api/remotes` 的 allowlist 是应用选择的唯一来源。每项显式标注 `emit` 或 `waterfall`,该 mode 同时决定 Host 监听方式、Client 合法键集和 wire frame 类型。 + +系统不声明 `RemoteInvocationMap`,不要求 Client 再写一份 `@Remote`,也不以最后一个运行时参数是否为函数来猜测调用模式。 + +Remote Event 下行帧是显式 discriminated union: + +```text +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } +``` + +WebSocket JSON 与进程内 carrier 的入口都从 `unknown` 开始按 `type` 和精确字段验证;验证完成后的分发只接收 typed union。TypeScript 静态类型不替代 wire 校验。 + +普通 `emit` 参数必须是无损 JSON。Client 在每个 Remote 实例私有的 Cordis key 上调用 `parallel()`,保留注册顺序、调用方 fiber 所有权和 listener 错误隔离。 + +私有 key 防止 Host 事件与 Client 本地同名 Cordis 事件互相触发。Client Remote 不维护自己的 subscription registry 或手写 listener chain。 + +可返回的 waterfall 当前只支持 Agent scope。事件签名必须是一个含直接 `agent` 字段的 request,加一个返回同类型结果的 `next()`,整体返回 Promise。 + +Host 只投影 request 一级的 `agent` 与 `signal`:`agent` 变为 frame 的一级 `agentId`,`signal` 成为 delivery lifetime,其余字段必须整体为无损 JSON。 + +Client 用 `agentId` 同步解析或物化 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`。Session-backed adapter 在首个成功 Session 列表 baseline 到达前允许 transport 先物化 scope;baseline 到达后由列表生命周期接管 scope 存活判断。 + +系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clone/restore Context 和 AbortSignal,也不等待未来出现的 Agent Context。 + +Client adapter 未注册、resolver 未返回 Context 或解析抛错时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。 + +Gateway Host 为每个未完成 waterfall 保存 `eventId`、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。 + +每个 generation 的队列保证一次投递,因此 Client 不保存 `seen` 集合。`clientId + eventId` 绑定结果与当前 generation,旧连接的回包不能完成新连接上的 delivery。 + +多 Client 同时接收 waterfall 时,第一个 result 或 rejection 完成 Host 调用,并向其余 Client 发送 `cancel`。只有所有已投递 Client 都返回 `next` 时,Gateway 才继续原 Cordis chain。 + +Host caller signal 取消、Agent Context 释放、Client generation 结束和 losing-client cancellation 都会终止对应的等待。 + +Client 通过现有 HTTP unary RPC `$events/result` 回送 `next`、result 或 rejection;下行事件仍复用 Remote WebSocket mux,不为应答建立 duplex WebSocket。 + +Gateway 只验证 waterfall 返回值能无损表示为 JSON,不解释业务字段。Question 回答的 option 归属等语义由请求方或 UI 领域承担,transport 不重复校验。 + +`UserQuestionService` 在请求期间观察到调用方 `AbortSignal` 已取消、且 provider 抛出普通错误时,将其归一为 `UserQuestionError` 的 `ASK_ABORTED`,并把原错误保留为 `cause`;provider 已给出的领域错误保持不变。 + +`$events/result` 失败会令当前 Connection generation 失败。Host 随 generation 撤销该 Client 的 delivery,pending event 在下一 generation 重放,Client 不维护第二套结果重试队列。 + +普通 `$on` 通知在断线后不重放。凡正确性依赖恢复的数据必须有 query、cursor 或 opening baseline,不能依赖 Remote Event 恰好送达。 + +Client listener 晚于事件到达才注册时不补送;HMR 也没有专用补投语义。 + +### API Proxy 的剩余边界 + +Session Controller 与 Workspace Controller 直接提供生成 Remote namespace;API Remotes 与 API Gateway 直接提供 Host-to-Client 事件。 + +Client Connection 只维护 Host generation、description 与通用 RPC,不解析领域 frame。 + +Client Runtime 只接收 Controller adapter 产出的领域变更,不识别 `HostFrame`、`session/subscribed`、`session/event` mux frame 或 `host/workspace-*` frame。 + +API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace、Remote Event 或 Connection generation 的依赖。 + +## 备选方案 + +**建立任意 Session stream 时自动恢复 Agent。** 这会让查看历史、读取 title、重连标签页或观察后台状态产生执行副作用,也会让多个浏览器触发重复恢复;冷日志和投影已有 persistence 来源。 + +**只允许 live Agent 使用 `session.follow`。** 这会迫使 transcript 首屏恢复 Agent,或重新引入 unary history 与 live subscription 之间的竞态;按 identity 先 follow 再冷读能同时覆盖历史和未来的显式激活。 + +**把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。 + +**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 + +**让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。 + +**给 Remote Event 新建一份 Client invocation 声明。** 第二张 map 或 Client `@Remote` 会复制 owner Cordis 事件签名并形成漂移点;从同一 `Events` 声明推导 `$on` listener 和结果类型可以构造性地保持一致。 + +**把 Agent scope 做成任意深度对象投影。** 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 `agent` 与 `signal` 足以覆盖当前 waterfall。 + +**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可同步解析或物化目标的 Client 增加额外生命周期;resolver 当下不能提供目标时立即 `next` 保持 Cordis waterfall 语义。 + +**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 + +**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 + +**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 + +## 验证 + +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 + +Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 + +`RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 + +`RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。 + +`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。 + +Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 + +Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。 + +Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。 + +Workspace Host 测试固定 baseline-first、upsert/remove、权威 order、archived set 和 follower disposal。 + +Workspace Client 测试固定 snapshot replacement、unary/stream 竞态、删除不复活、稳定排序和 terminal failure。 + +Remote Event 类型测试拒绝未选择事件、非 void 的 unscoped 事件、非 Agent-scoped waterfall 和签名不匹配的 mode。 + +Remote Event Host 测试固定 listener-before-ready、payload 校验、pending replay、多 Client first-result、all-next delegation、rejection、Host cancellation、Context release 和 losing-client cancel。 + +Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent Context 解析、`next`、result、rejection、cancel、旧 generation 回包拒绝和 `$events/result` 失败导致 generation 结束;User Question 测试固定进行中 signal 取消的错误归一化及 cause 保留。 + +缺失 source、重复 source、撤回 source、非 ready 首项、未知 discriminant、额外字段与非 JSON 值都在各自 wire 入口响亮失败。 + +静态检查固定 API Proxy 不再导出 Session/Workspace Host frame carrier,Client Runtime 不再包含对应 bridge。 + +## 后果 + +浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。 + +持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。 + +Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 + +Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 + +通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 + +Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host chain、断线重放 pending 和端到端取消;代价是当前协议只支持一级 Agent scope 与无损 JSON 请求/结果。 + +本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。 + +本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.i18n.yaml new file mode 100644 index 0000000000..780aa7e236 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.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/implemented/architecture/2026-08-19-shared-win32-process-primitives.md +2026-08-19-shared-win32-process-primitives.md: 8765e5f7350dab56ad42169f6e16b55679ca8982 +2026-08-19-shared-win32-process-primitives.zh.md: b21ece8445e8863c08818c42d6c9bf7672813823 diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md new file mode 100644 index 0000000000..8765e5f735 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox process primitives have one low-level owner + +Status: implemented + +English | [中文](2026-08-19-shared-win32-process-primitives.zh.md) + +## Problem + +The Windows ACL sandbox owns restricted-token, SID, DACL, grant, and workspace policy, but its process launch path also carried the generic Koffi ABI, command-line quoting, anonymous pipes, inherited stdio, Job setup, waits, and HANDLE cleanup. A second Windows process consumer would otherwise have to depend on sandbox policy or copy native resource logic, while fixes to allocation and failure cleanup would need to remain synchronized. + +## Decision + +`@deepseek-ai/dsh-win32-process` owns the reusable Win32 process ABI and native resource operations currently consumed by `sandbox-windows-acl`. The package lazily loads `kernel32.dll` and `advapi32.dll`, verifies the x64 `STARTUPINFOW` and `PROCESS_INFORMATION` layouts, quotes argv for `CreateProcessAsUserW`, and exposes checked restricted-token pipe and inherited-stdio Job operations. + +The Windows ACL sandbox remains the only owner of restricted-token creation, SID and DACL policy, grants, writable-path decisions, temporary-directory policy, and the public sandbox child result. It extends the shared binding context with policy-specific APIs, supplies the primary token, combines pipe drains and waits, and closes the caller-owned Job at its lifecycle boundary. + +Every native allocation and HANDLE has one owner within each shared operation. A process operation frees its Koffi out-parameters and closes every pipe, thread, process, or Job handle it acquired before a controlled failure. Successful pipe creation returns the process plus stdout/stderr read handles to the sandbox. Inherited-stdio creation starts the target suspended, assigns it to the kill-on-close Job, and resumes it only after assignment, so target code cannot run outside the Job. Assignment failure terminates the suspended target before releasing its handles; resume failure closes the assigned Job. The sandbox retains its existing pipe-drain, direct-wait, result, and returned-Job lifecycle. + +The package exports only operations used by the sandbox production path. Ordinary `CreateProcessW`, exact `applicationName`, parent-stdio release, and whole-Job settlement remain absent until an ordinary process consumer needs them. The package is a library, not a Cordis service or a public Windows SDK. + +## Verification + +The shared suite covers x64 ABI values, command-line quoting, binding extension, pipe EOF and drain allocation reuse, restricted-token process creation, suspended creation followed by Job assignment and resume, wait and exit-code reads, native allocation release, and the acquired-resource failure paths. Sandbox tests retain restricted-token, fail-closed, pipe/inherit, result, and disposal composition without duplicating the low-level matrix. The committed header probes and Windows package tests cover the migrated ABI and native paths; Wine supplies the emulated Windows package and composition signal. + +## Alternatives considered + +**Keep process primitives inside the sandbox package.** Rejected because a process consumer would inherit ACL/token policy or duplicate the native ABI and cleanup paths. + +**Copy the Koffi implementation into each consumer.** Rejected because struct layouts, error capture, and partial-failure cleanup would have multiple owners. + +**Publish ordinary-runner operations before a current consumer exists.** Rejected because unused `CreateProcessW`, application-name, parent-stdio, and Job-settlement APIs would freeze speculative obligations and enlarge the failure matrix. + +## Consequences + +The sandbox keeps its public behavior while generic Win32 resource ownership has one package and one test home. The package boundary adds one workspace dependency and a published library, and callers must explicitly own policy, scheduling, result composition, and returned HANDLE closure. Suspended creation guarantees that target code starts only after Job assignment, but it does not make the runner's create-to-assignment interval atomic against external termination. Future process consumers extend the low-level package only when their production path exists. diff --git a/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md new file mode 100644 index 0000000000..b21ece8445 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-shared-win32-process-primitives.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Windows sandbox process primitives 只有一个低层 owner + +Status: implemented + +[English](2026-08-19-shared-win32-process-primitives.md) | 中文 + +## Problem + +Windows ACL sandbox 拥有 restricted token、SID、DACL、grant 与 workspace policy,但其进程启动路径还同时承载通用 Koffi ABI、命令行引用、匿名管道、继承 stdio、Job 设置、wait 与 HANDLE 清理。第二个 Windows process consumer 否则只能依赖 sandbox policy 或复制 native resource 逻辑,而 allocation 与失败清理修复也必须在多份实现间保持同步。 + +## Decision + +`@deepseek-ai/dsh-win32-process` 拥有 `sandbox-windows-acl` 当前消费的可复用 Win32 process ABI 与 native resource 操作。该包惰性加载 `kernel32.dll` 和 `advapi32.dll`,核验 x64 `STARTUPINFOW` 与 `PROCESS_INFORMATION` 布局,为 `CreateProcessAsUserW` 引用 argv,并提供带检查的 restricted-token pipe 与 inherited-stdio Job 操作。 + +Windows ACL sandbox 继续唯一拥有 restricted-token 创建、SID 与 DACL policy、grants、可写路径裁定、临时目录 policy 和公共 sandbox child result。它通过共享 binding context 扩展 policy-specific API,提供 primary token,组合 pipe drain 与 wait,并在自己的生命周期边界关闭调用方拥有的 Job。 + +每项 native allocation 与 HANDLE 在各个 shared operation 内只有一个 owner。process operation 会释放 Koffi out-parameter,并在受控失败前关闭它已经取得的每个 pipe、thread、process 或 Job handle。pipe 创建成功时,把 process 与 stdout/stderr read handles 返回给 sandbox。inherited-stdio 创建以 suspended 状态启动目标,把它分配给 kill-on-close Job,并只在分配后恢复,因此目标代码不会在 Job 外运行。分配失败会先终止 suspended target 再释放句柄;恢复失败会关闭已经分配的 Job。sandbox 保留既有 pipe-drain、direct-wait、result 与返回 Job 的生命周期。 + +该包只导出 sandbox 生产路径已使用的操作。ordinary `CreateProcessW`、精确 `applicationName`、parent-stdio release 与 whole-Job settlement 在 ordinary process consumer 出现前保持缺席。该包是 library,不是 Cordis service 或公共 Windows SDK。 + +## Verification + +shared suite 覆盖 x64 ABI 值、命令行引用、binding extension、pipe EOF 与 drain allocation 复用、restricted-token process 创建、suspended 创建后的 Job 分配与恢复、wait 与 exit-code 读取、native allocation 释放,以及已取得资源的失败路径。sandbox 测试保留 restricted-token、fail-closed、pipe/inherit、result 与 disposal 组合行为,不重复低层矩阵。已提交的 header probe 与 Windows package 测试覆盖迁移后的 ABI 和 native 路径;Wine 提供模拟 Windows package 与组合信号。 + +## Alternatives considered + +**把 process primitives 留在 sandbox package。** 拒绝,因为 process consumer 将被迫继承 ACL/token policy,或复制 native ABI 与清理路径。 + +**为每个 consumer 复制 Koffi 实现。** 拒绝,因为 struct layout、错误捕获与局部失败清理会出现多个 owner。 + +**在当前 consumer 出现前发布 ordinary-runner operations。** 拒绝,因为未使用的 `CreateProcessW`、application-name、parent-stdio 与 Job-settlement API 会冻结推测性义务,并扩大失败矩阵。 + +## Consequences + +sandbox 保持公共行为,而通用 Win32 resource ownership 只有一个 package 与一个测试归属。该 package boundary 增加一个 workspace dependency 和发布 library;调用方必须显式拥有 policy、调度、result 组合与返回 HANDLE 的关闭责任。suspended 创建保证目标代码只在 Job 分配后启动,但不会让 runner 的 create-to-assignment 区间对外部终止具备原子性。后续 process consumer 只在其生产路径存在时扩展低层 package。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml new file mode 100644 index 0000000000..f616ad829c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa +2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md new file mode 100644 index 0000000000..8e5521ff19 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -0,0 +1,461 @@ +# Agent Note: Client Session, Conversation, and UI ownership layers + +Status: implemented + +English | [中文](2026-08-20-client-session-conversation-ownership.zh.md) + +## Problem + +The Web Client once placed Session and Workspace objects, event windows, Conversation assembly, React hooks, the Slot registry, and the Store engine in one general Runtime. Protocol state, business projections, React bindings, and page presentation shared one dependency hub, so a change in any layer could spread across the entire frontend. + +Session snapshots could also accumulate data they did not own, including event arrays, Conversation Views, Chat Nodes, and pending interactions. Ordinary consumers then had to understand event replay and concrete views, while adding a Conversation target could require changes to Session, Runtime, and the renderer. + +Without an explicit interface between React and Session lifetimes, binding release, Hook source replacement, and Slot store cleanup became dedicated callback protocols. Approval and Question both affect sidebar state and composer takeover; independently maintained state could make those surfaces select different pending requests. + +The Client needs one-way dependencies between data owners, React adapters, generic rendering machinery, and concrete views while preserving application behavior. + +## Decision + +The Client uses the layering “Controller and domain object → UI adapter → renderer → Slot component.” Controllers and domain objects publish React-free observable sources; their `ui-*` packages declare standard props and register sources; `ui-renderer` creates selector hooks at Slot binding points; components read data and actions only from Slot props. + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Client Session and Workspace objects belong to `api/session-controller/client` and `api/workspace-controller/client`, respectively. Target-neutral Conversation data structures and assembly belong to `client/ui-conversation`; Chat and Trajectory belong to `client/ui-chat` and `client/ui-trajectory`, respectively. + +The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`. + +The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers. + +## Layering principles + +### Controllers are React-free logic owners + +A Controller may be installed as a Cordis service, but it does not own React Contexts, React hooks, Slot props, or components. A Controller snapshot contains only facts that it owns, and its commands change only Host or domain-object state. + +The UI layer may read multiple Controllers for one navigation decision, but it does not write the combined result back into any Controller snapshot. A UI adapter does not duplicate a Controller command's business implementation. + +### UI adapters own React integration + +Each standard hook belongs to the `ui-*` package closest to its data semantics. + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller global list | +| `useSession` | `client/ui-session` | Current Session snapshot | +| `useProjection` | `client/ui-session` | Current Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller list | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` implements only generic binding. It does not import Session, Workspace, Conversation, Chat, or Trajectory business types or values. + +### Slot scopes and standard props are separate + +`ui-slots` declares root, session, and session-maybe scopes plus declaration-merge-extensible standard prop types. It does not decide which hooks each scope installs. + +`ui-renderer` implements generic scope adapters and source materialization. `ui-session` installs the Session scope and supplies its built-in sources; other domain packages register only their own sources and the Slot entries that consume them. + +Adding a target does not add a branch to the renderer or Session Controller. The data owner handles state identity, updates, errors, and release; the UI adapter owns the hook; the presentation owner owns target-specific projections and interaction state. + +## Package ownership + +| Package | Owns | Explicitly does not own | +| --- | --- | --- | +| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace | +| `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI | +| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | +| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | +| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | +| `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | +| `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | +| `client/store` | React-free Store contract and implementation | Domain objects, React hooks, Slot lifetimes | +| `client/ui-renderer` | SlotRegistry, scope binding, selector hooks, outlets, and React root | Session, Workspace, and Conversation business logic | + +## Overall data flow + +Session data reaches the UI through this path: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command. + +Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection. + +## Session Controller Client + +### Scope of SessionSnapshot + +`SessionSnapshot` represents control and lifecycle facts belonging to a Session. It may contain identity, running, removed, blank, subagent address, open phase, history phase, prompt error, agent error, and queue state. + +It does not contain: + +- a raw event array; +- Conversation Views; +- Chat Nodes; +- Trajectory rows; +- pending Approval or Question objects; +- presentation state that requires callers to traverse events. + +Whether a field derives from an event, control frame, or local command does not automatically determine its owner; consumption semantics determine ownership. `composerPhase` depends on both Session lifecycle and Conversation target activity, so `ui-conversation` composes it instead of placing it in `SessionSnapshot`. + +### Three read faces + +The Session Controller exposes three distinct read faces: + +1. The global Session list and current-selection source, used by navigation and `useSessions`. +2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources. +3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core. + +Ordinary UI components do not read `SessionEventSource` directly. `ui-session` does not read private event windows, and the `ui-conversation` core receives neither React bindings nor Slot APIs. + +### SessionEventSource + +`SessionEventSource` exposes a materialized event window, not a transport. + +The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. Append links an immutable segment in constant time; a consumer that needs the complete `entries` array materializes and caches it for that snapshot. + +Initial open, reconnect, gap repair, and updates whose continuity cannot be proven publish `replace`; history pagination publishes `prepend`; a continuous live event publishes `append`. The Conversation core selects incremental update or complete rebuild from the revision and change. + +`MutableSessionEventSource` is the Session Controller's internal write face. Consumers depend only on the read-only `SessionEventSource`. + +### Session binding lifecycle + +Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding. + +Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol. + +This cleanup does not require the Session Controller to know the roster of upper-layer consumers. + +## UI Session + +### Service responsibilities + +`client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and: + +- observes the Session list, current selection, and per-Session bindings; +- installs the session and session-maybe scope adapters; +- supplies `SessionProvider` rendering semantics; +- supplies built-in Session snapshot, projection, and sessionId sources; +- accepts Session-scoped source contributions from other domain packages; +- aggregates pending interactions registered by business packages. + +It does not own Session transport, event folding, Conversation targets, or concrete business results. + +### Standard source registration + +A domain package calls `ctx.uiSession.provide()` to register a bare source. The descriptor statically declares its hook, keyed-hook, and prop rosters; `resolve(binding)` returns exactly those values for one Session binding. For example, `ui-conversation` registers each binding's snapshot as the `conversation` hook source. + +The renderer converts an ordinary source into `use`. Open key spaces such as projections use a keyed-hook resolver, while stable values use props. + +The runtime rejects undeclared, missing, or duplicate standard props. `ui-session` materializes its own built-ins through the same mechanism, so the renderer has no Session-specific name branches. + +### Scope binding + +session and session-maybe use the same adapter with different binding semantics: + +- a strict session scope refuses to render without a current binding; +- session-maybe uses a stable absent binding to preserve hook call order; +- changing the current Session rebuilds the strict Session subtree under the `sessionId` key; +- root and session-maybe entries may remain mounted across Session changes. + +Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`. + +Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching. + +### SessionProvider + +`SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components. + +It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly. + +Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback. + +### Pending interactions + +Business packages extend `SessionPendingInteractionMap` through declaration merging. Every pending object carries at least a stable `key`, domain `kind`, and `sessionId`; `ui-session` does not import concrete Approval or Question types. + +A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object together with its waterfall-delegation callback and returns an idempotent disposer for that object. Plugin teardown removes all published objects before invoking and awaiting their delegation callbacks, so active Host requests cannot remain suspended after their Client answerer unloads. + +Concurrent objects with the same key are rejected; replacement requests use a new key. One Session may hold multiple domains or requests at once. + +`ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins. + +The aggregate is published as `pendingInteractions: ObservableSnapshot>`; `useSessionPendingInteraction` is its React read face. + +Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters. + +## Workspace Controller and UI Workspace + +### Scope of WorkspaceSnapshot + +`WorkspaceSnapshot` contains only Host-authoritative data owned by the Workspace Controller, including Workspace rows, order, archive set, follow phase, and errors. A Workspace row's `sessionIds` is an association field, not a copy of Session objects in the Workspace snapshot. + +These combined facts do not enter `WorkspaceSnapshot`: + +- whether the Workspace and Session baselines are both ready; +- the most recent Workspace derived from Session update times; +- whether the current Session is cleared because it was archived; +- which blank Session New Session should reuse; +- which Session initial startup should select. + +### UI Workspace composition responsibilities + +`client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`. + +Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot. + +Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller. + +## UI Conversation + +### Assembly core + +`client/ui-conversation` contains both the React-free Conversation assembly core and the React adapter for the same domain. + +The core owns `ConversationSnapshot`, the Definition registry, the View registry, the event assembler, the location index, per-Session bindings, target sources, and target activity. + +The core obtains `SessionEventSource` from a Session binding. Append and prepend changes with continuous revisions use incremental assembly; replace changes or revision gaps rebuild from the complete window. + +Definition or View roster changes rebuild only the Conversation binding; they do not rebuild a Session or reopen a Remote stream. The core does not import React and can test event folding, incremental updates, and registry lifetimes independently. + +`ConversationSnapshot` does not copy `SessionSnapshot` or expose raw events. It publishes only the target-neutral View roster, target activity, and target-source lookup. + +`useSession` and `useConversation` come from separate sources and are not guaranteed to publish atomically in one React commit. Components that read both compute purely from their current snapshots and do not treat notification order as business causality. + +### Definition and View registries + +`UiConversation.events` is the sole registry for event Definitions, and `UiConversation.views` is the sole registry for target snapshot builders. + +The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes; changes in one synchronous registration turn are coalesced into one microtask rebuild. + +A target package extends snapshot and location-data maps through declaration merging, then registers its Definitions, builder, and View. Registrations follow Cordis effect disposal. + +`ui-conversation` does not import concrete target packages. + +### Conversation React adapter + +The React adapter registers each Conversation binding snapshot as the Session standard source `conversation`, from which the renderer provides `useConversation`. + +The package also owns the shell, input, composer chain, queue UI, drafts, View navigation, and phase composition. The core reads no React Context, Slot props, or component state. + +View selection order is a valid persisted selection, registered `chat`, then no View. An invalid selection does not overwrite the persisted value, and the system does not fall back to the first registered View. + +Without `ui-chat`, the shell can still activate and mount but does not implicitly select Trajectory or another target. + +The shell phase is a pure composition of Session lifecycle and Conversation target activity. An active Session or any target reporting visible content produces active; a failed first prompt remains engaging. + +### Input and composer + +The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`. + +A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation. + +Draft and input state belong to Conversation UI and do not enter the Session snapshot. Queue commands use a Session-scoped service for addressing and do not write queue UI into the Conversation core. + +## Chat and Trajectory targets + +### Chat owner + +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. + +It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. + +Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. + +The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. + +### Trajectory owner + +`client/ui-trajectory` registers `trajectory` through the same target protocol. It owns event-record projection, timelines, virtual rows, selection, and the inspection view, and exposes `useTrajectory` through a standard source. + +Session lifecycle reads `useSession`, while Trajectory data reads `useTrajectory`. Trajectory does not obtain its own data through a Session or Chat snapshot. + +Other targets use the same registration flow without modifying the renderer, Session Controller, or ui-session. + +## Approval and Question + +### Stable registration + +Approval and Question plugin installation separates stable registrations from per-request handling. `apply()` registers locale data, calls `registerPendingInteraction()` once for its pending domain, and registers one stable entry in `conversation.composer`. + +The stable Approval entry also declares its detail child Slot. Concurrent requests and Session count do not add composer entries or redeclare Slots, and every registration follows plugin-fiber disposal. + +### One waterfall request + +A Remote Event listener resolves the Session from its own Agent Context. Without a Session scope it calls `next()` to continue the waterfall; with a Session scope it creates a `PendingApproval` or `PendingQuestion`. + +The listener publishes the object through the registered domain publication function, waits for user completion, cancellation, or request-signal abortion, and removes the exact object in `finally`. + +One request does not register a Slot, create another lifecycle effect, or mutate the Session snapshot. + +Approval exposes allow and reject; Question exposes answer and cancel. User cancellation of a Question returns `ASK_CANCELLED`; interruption of a pending request by `AbortSignal` returns `UserQuestionError(ASK_ABORTED)` rather than leaking the carrier's `AbortError` or an ordinary `Error`. + +The Gateway requires only that Remote Event arguments and results are valid JSON transport values. It does not duplicate domain validation of Question options. + +### One pending projection + +The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity. + +The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change. + +`ui-session` implements only cross-domain precedence and does not interpret Approval or Question fields. + +## UI Renderer and Store + +### UI Renderer + +`client/ui-renderer` owns the `SlotRegistry` service and React renderer. It is responsible for: + +- `ctx.slots.register()`, `inject()`, `renderSlot()`, and declaration lifetimes; +- root, session, and session-maybe scope adapters; +- binding standard observable sources to selector hooks; +- Slot outlets, error isolation, root mount, and hydration; +- managing Slot store instance lifetimes by scope key. + +The renderer may know generic scope names and binding protocols but does not read domain services. Rendering Session scope without an installed adapter is an assembly error that fails immediately. + +### Store + +`client/store` is a plain React-free library owning `ObservableSnapshot`, `SnapshotStore`, `defineStore`, `createSnapshotStore`, and `shallowEqual`. + +`ui-slots` references the Store contract; `ui-renderer` manages Store instances and supplies `useStore`. + +Stores hold viewing and interaction state such as drafts, View selection, Chat selection, inspection requests, and panel size. Session, Workspace, Conversation, Remote streams, and connection generations do not enter Stores. + +### Registration and release order + +When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook. + +Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers. + +Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle. + +## Composition and dependency direction + +The application bundle explicitly installs the required Controller, adapter, target, and renderer plugins. Each owner's `apply()` installs only its own service, listener, and contributions. + +Runtime consumption flows as `session-controller → ui-session → ui-conversation → target UI`, `workspace-controller → ui-workspace`, and `store → ui-slots → ui-renderer`; Approval and Question depend only on the pending-registration point exposed by `ui-session`. + +Arrows in this description represent runtime consumption and do not include type-only declaration-merge edges. Controllers do not depend back on UI adapters, the renderer does not depend back on domain packages, and the Conversation core does not depend on a concrete target. + +UI components do not receive `ctx`. Cross-package collaboration uses Cordis services, standard sources, or Slot registrations without introducing an aggregate facade. + +## Developer guidance + +### Choose the data owner first + +Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction. + +The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object. + +These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller. + +### Add Session-scoped data + +1. Provide a React-free observable source in the domain owner. +2. Declaration-merge the standard prop type in the owning UI adapter. +3. Declare a fixed roster through `ctx.uiSession.provide()` and resolve its source from a Session binding. +4. Let the Slot component receive the generated hook through `PropsRuntime`; do not pass `ctx` to a component. +5. Attach each binding resource's cleanup to `binding.ctx.effect()` and leave registration cleanup to the plugin fiber. +6. Test missing values, duplicate names, roster replacement, Session changes, and binding disposal. + +Only open key spaces use keyed hooks. Finite stable sources use ordinary hooks, and immutable identifiers use props. Do not hard-code business names in the renderer to save one registration. + +### Add a Conversation target + +1. Extend the Conversation snapshot or location-data map in the target package. +2. Register the required event Definitions with `UiConversation.events`. +3. Register the snapshot builder, target id, View, and activity rule with `UiConversation.views`. +4. Expose the target's standard selector hook through `ctx.uiSession.provide()`. +5. Register the renderer, locale, and target-specific Slot entries in the same package. +6. Verify that unloading the target rebuilds only the Conversation binding without changing the Session, other targets, or Remote stream. + +A target must not use another target's snapshot as its data source. Optional collaboration uses a narrow port or Slot; when a target is absent, the shell remains bootable and does not guess a fallback. + +### Add a pending-interaction domain + +1. Define the Pending object and its completion, cancellation, and interruption semantics in the business package. +2. Add the object to `SessionPendingInteractionMap` through declaration merging. +3. Call `registerPendingInteraction()` once in `apply()` and register one stable composer entry. +4. Resolve the Session from the Agent Context in the Remote waterfall listener; call `next()` when the listener cannot handle the request. +5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`. +6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session. + +A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`. + +### Review checks + +- Every new source, registry contribution, listener, and cache has an explicit Cordis-fiber or Session-binding owner. +- Every public hook traces to one React-free source; no selector is forwarded through layers only to pass arguments. +- Every component obtains data and actions from standard props or the owning Slot's inject face. +- Every target has defined behavior when absent, dynamically registered, and unloaded. +- Every cross-layer import advances in the one-way Controller, adapter, renderer, component direction. +- Each error is classified by the earliest owner that can explain its semantics; carrier errors do not leak directly as business errors. + +## Verification + +Tests owned by each layer pin Controller bindings and event sources, UI scopes and pending precedence, incremental Conversation assembly and View fallback, target projections, waterfall results, and renderer scope/Store lifetimes. Application-composition tests cover both the complete roster and startup without a concrete target; component tests do not replace object-layer, replay, and lifecycle tests. + +## Alternatives considered + +- **Keep a Runtime facade.** One entry point would retain the dependency hub and let new code bypass domain owners, so the system provides neither the facade nor a compatibility export. +- **Put all Client state in API Controllers.** Protocol objects would then own React, Views, and presentation policy, so Controllers retain only React-free domain state. +- **Let Controllers provide React hooks directly.** Non-React consumers could not reuse the same objects, and transport and renderer lifetimes would become interdependent. +- **Put Conversation in SessionSnapshot.** This would expand the Session API and force ordinary Session consumers to understand event folding and target rosters. +- **Let Chat and Trajectory replay Session events independently.** Ordering, locations, and registry rebuild would be duplicated, so the shared assembly core stays in `ui-conversation`. +- **Extract the Conversation core into another non-UI package.** The core and adapter currently evolve together and have no other non-UI package consumer; directory separation within one package keeps the core React-free. +- **Combine Workspace and Session into one snapshot.** This would create another cross-domain owner, so cross-domain logic remains an immediate decision in `ui-workspace`. +- **Build every standard hook into the renderer.** Generic infrastructure would need to know every domain, so standard-source registration keeps the renderer independent from business types. +- **Dynamically register a composer entry for every pending request.** This would redeclare child Slots and make concurrent requests compete through registration order, so stable entries are separate from request publication. +- **Write pending interactions into a Session projection.** An unanswered waterfall is not a committed durable Session fact; Remote Event replay restores it after refresh, so it remains in a business UI source. +- **Add a dedicated release callback to bindings.** This would duplicate the Cordis lifecycle; `binding.ctx.effect()` already attaches consumer cleanup to the same owner. +- **Pass the Session id from SessionProvider through a render function.** This would create another data-injection path; ordinary children and the standard `sessionId` prop retain one entry for scoped data. +- **Keep Store in the renderer.** The Store contract does not depend on React and is reused by objects and test infrastructure, so `client/store` keeps the engine separate from rendering lifetimes. + +## Consequences + +Session, Workspace, Conversation, and each concrete target own one authoritative state. Non-React consumers can reuse Controllers and the assembly core directly. A new Conversation target registers its Definition, builder, View, standard source, and Slot entries; a new pending-interaction domain declares its type, registers its domain, and provides one stable composer entry. + +The renderer and Session Controller gain no branch for a new business domain, while Session bindings and plugin fibers provide two explicit, composable release paths. The UI can observe independent Session and Conversation source publications, and consumers cannot depend on their notification order. + +Composition packages must explicitly load the required adapter and target plugins. The shell remains operational without a concrete target but neither creates nor guesses that target's View. More packages and explicit registrations add assembly work, while dependency direction, test scope, and failure ownership become locally identifiable. diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md new file mode 100644 index 0000000000..a007a42b3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -0,0 +1,461 @@ +# Agent Note: Client Session、Conversation 与 UI 所有权分层 + +Status: implemented + +[English](2026-08-20-client-session-conversation-ownership.md) | 中文 + +## 问题 + +Web Client 曾由一个通用 Runtime 同时承载 Session 与 Workspace 对象、事件窗口、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。协议状态、业务投影、React 绑定和页面呈现共享同一个依赖汇点,任何一层的变化都可能扩大到完整前端。 + +Session 快照也容易混入事件数组、Conversation View、Chat Node 和待处理交互等并非 Session 自身拥有的数据。普通消费者由此需要理解事件重放与具体视图,新增一个 Conversation target 也可能要求修改 Session、Runtime 和 renderer。 + +React 生命周期与 Session 生命周期之间缺少明确接口时,binding 释放、Hook source 替换和 Slot store 清理会演变为互相回调的专用协议。Approval 与 Question 同时影响侧边栏状态和 composer takeover;若两处各自维护状态,它们还可能选择不同的待处理请求。 + +需要把数据 owner、React adapter、通用渲染机制和具体视图拆成单向依赖,同时保持既有应用行为。 + +## 决定 + +Client 采用“Controller 与领域对象 → UI adapter → renderer → Slot component”的分层。Controller 和领域对象发布不依赖 React 的 observable source;所属 `ui-*` package 声明标准 props 并注册 source;`ui-renderer` 在 Slot binding 点生成 selector hook;组件只从 Slot props 读取数据与操作。 + +```text +[Remote / Controller / domain object] + | + | bare observable source + v + [ui-* adapter] + | + | standard source registration + v + [ui-renderer] + | + | selector hook binding + v + [Slot component] +``` + +Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`。Conversation 的 target-neutral 数据结构和组装归 `client/ui-conversation`,Chat 与 Trajectory 分别归 `client/ui-chat` 和 `client/ui-trajectory`。 + +Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。 + +系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。 + +## 分层原则 + +### Controller 是无 React 的逻辑 owner + +Controller 可以作为 Cordis service 安装,但不拥有 React Context、React hook、Slot props 或组件。Controller snapshot 只包含自身拥有的事实,命令只改变 Host 或领域对象状态。 + +UI 层可以同时读取多个 Controller 做一次导航决定,但不得把组合结果写回任一 Controller snapshot。UI adapter 也不复制 Controller 命令的业务实现。 + +### UI adapter 拥有 React 接入 + +每个标准 hook 归最接近其数据语义的 `ui-*` package。 + +| Hook | Owner | Source | +| --- | --- | --- | +| `useSessions` | `client/ui-session` | Session Controller 全局列表 | +| `useSession` | `client/ui-session` | 当前 Session snapshot | +| `useProjection` | `client/ui-session` | 当前 Session keyed projection | +| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 | +| `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 | +| `useConversation` | `client/ui-conversation` | Conversation binding snapshot | +| `useChat` | `client/ui-chat` | `chat` target source | +| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source | + +`ui-renderer` 只实现通用绑定,不 import Session、Workspace、Conversation、Chat 或 Trajectory 的业务类型和值。 + +### Slot scope 与标准 props 分离 + +`ui-slots` 声明 root、session 和 session-maybe scope,以及可通过 declaration merge 扩展的标准 props 类型;它不决定每个 scope 安装哪些 hook。 + +`ui-renderer` 实现通用 scope adapter 与 source materialization。`ui-session` 安装 Session scope 并提供内建 source,其他领域 package 只注册自己的 source 和消费它的 Slot entry。 + +新增 target 不要求 renderer 或 Session Controller 增加分支。数据 owner 负责状态身份、更新、错误和释放;UI adapter 负责 hook;显示 owner 负责 target-specific projection 与交互状态。 + +## Package 所有权 + +| Package | 拥有内容 | 明确不拥有 | +| --- | --- | --- | +| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace | +| `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI | +| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | +| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | +| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | +| `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | +| `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | +| `client/store` | React-free store contract 与实现 | 领域对象、React hook、Slot 生命周期 | +| `client/ui-renderer` | SlotRegistry、scope binding、selector hook、outlet 和 React root | Session、Workspace 与 Conversation 业务逻辑 | + +## 总体数据流 + +Session 数据按以下路径进入 UI: + +```text +[ctx.remote.session] + | + v +[api/session-controller/client] + |-- SessionListState --------------------------> [ui-session] -> useSessions + |-- SessionSnapshot ----------------------------> [ui-session] -> useSession + |-- ProjectionValueSource ----------------------> [ui-session] -> useProjection + `-- per-Session SessionEventSource + | + v + [client/ui-conversation] + | + | assemble + v + ConversationSnapshot ----------------> useConversation + | + |---------+----------| + v v + [ui-chat] [ui-trajectory] + | | + useChat useTrajectory +``` + +Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。 + +Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。 + +## Session Controller Client + +### SessionSnapshot 的范围 + +`SessionSnapshot` 表示 Session 自身的控制与生命周期事实。它可以包含 identity、running、removed、blank、subagent address、open phase、history phase、prompt error、agent error 和 queue 状态。 + +它不包含以下数据: + +- raw event array; +- Conversation View; +- Chat Node; +- Trajectory row; +- Approval 或 Question 的待处理对象; +- 要求调用者遍历 event 才能解释的呈现状态。 + +字段由 event、control frame 或本地命令推导,并不自动决定其 owner;消费语义决定 owner。`composerPhase` 同时依赖 Session lifecycle 与 Conversation target activity,因此由 `ui-conversation` 合成,不进入 `SessionSnapshot`。 + +### 三个读取面 + +Session Controller 对外提供三个互不替代的读取面: + +1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。 +2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。 +3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。 + +普通 UI component 不直接读取 `SessionEventSource`。`ui-session` 不读取私有 event window,`ui-conversation` core 也不接收 React binding 或 Slot API。 + +### SessionEventSource + +`SessionEventSource` 暴露已经物化的事件窗口,而不是 transport。 + +窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。Append 以常数时间连接不可变片段;需要完整 `entries` 数组的消费者才为该 snapshot 物化并缓存数组。 + +首次打开、重连、gap repair 和无法证明连续性的更新发布 `replace`;历史分页发布 `prepend`;连续 live event 发布 `append`。Conversation core 依据 revision 与 change 选择增量更新或完整 rebuild。 + +`MutableSessionEventSource` 是 Session Controller 内部写端,消费者只依赖只读的 `SessionEventSource`。 + +### Session binding 生命周期 + +每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。 + +依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。 + +这种清理方式不要求 Session Controller 了解上层消费者名册。 + +## UI Session + +### 服务职责 + +`client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责: + +- 观察 Session list、current selection 和 per-Session binding; +- 安装 session 与 session-maybe scope adapter; +- 提供 `SessionProvider` 的呈现语义; +- 内建 session snapshot、projection 和 sessionId source; +- 接收其他领域 package 的 Session-scoped source contribution; +- 聚合业务 package 注册的 pending interaction。 + +它不拥有 Session transport、event folding、Conversation target 或具体业务结果。 + +### 标准 source 注册 + +领域 package 调用 `ctx.uiSession.provide()` 注册 bare source。Descriptor 静态声明 hooks、keyedHooks 和 props 名册,`resolve(binding)` 为一个 Session binding 返回完全对应的值;例如 `ui-conversation` 把每个 binding 的 snapshot 注册为 `conversation` hook source。 + +普通 source 被 renderer 转换成 `use`,Projection 等开放 key 空间通过 keyed hook resolver 暴露,稳定值通过 props 暴露。 + +运行时拒绝未声明、缺失或重复的标准 prop。`ui-session` 自身也走相同 materialization,renderer 不为 Session 名字写特殊分支。 + +### Scope binding + +session 与 session-maybe 使用同一个 adapter,但绑定语义不同: + +- strict session scope 在没有 current binding 时拒绝渲染; +- session-maybe 使用稳定 absent binding,保持 hook 调用顺序; +- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree; +- root 与 session-maybe entry 可以跨 Session 切换常驻。 + +每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。 + +Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。 + +### SessionProvider + +`SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。 + +它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。 + +Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。 + +### Pending interaction + +`SessionPendingInteractionMap` 由业务 package declaration merge 扩展。每个 pending object 至少携带稳定 `key`、领域 `kind` 和 `sessionId`;`ui-session` 不 import Approval 或 Question 的具体类型。 + +业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 同时发布精确对象及其 waterfall 委托回调,并返回移除该对象的幂等 disposer。Plugin teardown 会先移除所有已发布对象,再调用并等待其委托回调,避免 Client 回答者卸载后 Host 请求继续悬挂。 + +相同 key 的并发对象被拒绝,替换请求必须使用新 key。同一 Session 可以同时存在多个领域或多个请求。 + +`ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。 + +聚合结果发布为 `pendingInteractions: ObservableSnapshot>`,`useSessionPendingInteraction` 是其 React 读取面。 + +Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。 + +## Workspace Controller 与 UI Workspace + +### WorkspaceSnapshot 的范围 + +`WorkspaceSnapshot` 只包含 Workspace Controller 拥有的 Host-authoritative 数据,包括 Workspace rows、顺序、archive set、follow phase 和错误。Workspace row 的 `sessionIds` 是关联字段,不等于把 Session 对象复制进 Workspace snapshot。 + +以下组合事实不进入 `WorkspaceSnapshot`: + +- Workspace 与 Session 两条 baseline 是否同时 ready; +- 根据 Session 更新时间推导的最近 Workspace; +- 当前 Session 是否因归档而清除; +- New Session 应复用哪个 blank Session; +- 首次启动应选择哪个 Session。 + +### UI Workspace 的组合职责 + +`client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。 + +初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。 + +目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。 + +## UI Conversation + +### Assemble core + +`client/ui-conversation` 同时包含不依赖 React 的 Conversation assemble core 和同领域的 React adapter。 + +Core 拥有 `ConversationSnapshot`、Definition registry、View registry、event assembler、location index、每 Session binding、target source 和 target activity。 + +Core 从 Session binding 取得 `SessionEventSource`。连续 revision 的 append 与 prepend 使用增量组装;replace 或 revision 断档从完整窗口 rebuild。 + +Definition 或 View roster 变化只重建 Conversation binding,不重建 Session 或重开 Remote stream。Core 不 import React,可独立测试事件折叠、增量更新和 registry lifecycle。 + +`ConversationSnapshot` 不复制 `SessionSnapshot`,也不暴露 raw events;它只发布 target-neutral 的 View 名册、target activity 和 target source lookup。 + +`useSession` 与 `useConversation` 来自两个 source,不承诺在同一个 React commit 原子发布。同时读取两者的组件按当前 snapshot 纯计算,不把通知顺序解释为业务因果。 + +### Definition 与 View registry + +`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。 + +Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建;同一同步注册轮次中的变化会合并为一次 microtask 重建。 + +Target package 通过 declaration merge 扩展 snapshot 与 location data map,再向 registry 注册自己的 Definition、builder 和 View。注册随 Cordis effect 释放。 + +`ui-conversation` 不 import 具体 target package。 + +### Conversation React adapter + +React adapter 把每个 Conversation binding 的 snapshot 注册为 Session 标准 source `conversation`,renderer 由此提供 `useConversation`。 + +同包还拥有 shell、input、composer chain、queue UI、draft、View navigation 和 phase 合成;Core 不读取 React Context、Slot props 或 component state。 + +View 选择顺序固定为:有效的持久化 selection、已注册的 `chat`、无 View。无效 selection 不覆盖持久化值,系统不 fallback 到第一个已注册 View。 + +没有 `ui-chat` 时 shell 仍能激活和 mount,但不会隐式选择 Trajectory 或其他 target。 + +Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。Session 已 active 或任一 target 报告可见内容时显示 active;首条 prompt 失败仍保持 engaging。 + +### Input 与 composer + +Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。 + +Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。 + +Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue command 通过 Session-scoped service 寻址,不把 queue UI 写入 Conversation core。 + +## Chat 与 Trajectory target + +### Chat owner + +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 + +它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 + +Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 + +历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 + +### Trajectory owner + +`client/ui-trajectory` 通过相同 target 协议注册 `trajectory`。它拥有事件记录、时间线、虚拟行、selection 和 inspection view,并通过标准 source 提供 `useTrajectory`。 + +Session 生命周期读取 `useSession`,Trajectory 数据读取 `useTrajectory`。Trajectory 不通过 Session snapshot 或 Chat snapshot 取得自己的数据。 + +其他 target 使用同一注册流程,不修改 renderer、Session Controller 或 ui-session。 + +## Approval 与 Question + +### 稳定注册 + +Approval 和 Question 的 plugin 安装分为稳定注册与单次请求处理。`apply()` 注册 locale、调用 `registerPendingInteraction()` 注册本领域 pending domain,并向 `conversation.composer` 注册唯一稳定 entry。 + +Approval 的 detail child Slot 也由稳定 entry 声明。并发请求和 Session 数量不会增加 composer entry 或重复声明 Slot,所有注册随 plugin fiber 释放。 + +### 单次 waterfall 请求 + +Remote Event listener 从自身 Agent Context 解析 Session。没有 Session scope 时调用 `next()` 继续 waterfall;存在 Session scope 时创建 `PendingApproval` 或 `PendingQuestion`。 + +Listener 通过已注册 domain 的 publication function 发布对象,等待用户完成、取消或请求 signal 中止,并在 `finally` 中精确移除对象。 + +单次请求不注册 Slot,不创建第二套 lifecycle effect,也不修改 Session snapshot。 + +Approval 暴露 allow 与 reject,Question 暴露 answer 与 cancel。用户主动取消 Question 返回 `ASK_CANCELLED`;等待中的请求被 `AbortSignal` 中止时返回 `UserQuestionError(ASK_ABORTED)`,不泄漏载体的 `AbortError` 或普通 `Error`。 + +Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复制 Question 选项的领域校验。 + +### 单一 pending 投影 + +Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。 + +同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。 + +`ui-session` 只实现跨领域 precedence,不解释 Approval 或 Question 的字段。 + +## UI Renderer 与 Store + +### UI Renderer + +`client/ui-renderer` 拥有 `SlotRegistry` service 和 React renderer。它负责: + +- `ctx.slots.register()`、`inject()`、`renderSlot()` 与声明生命周期; +- root、session 和 session-maybe scope adapter; +- 标准 observable source 到 selector hook 的绑定; +- Slot outlet、错误隔离、root mount 与 hydration; +- 按 scope key 管理 Slot store instance 生命周期。 + +Renderer 可以认识通用 scope 名称和 binding 协议,但不读取领域 service。渲染 Session scope 而没有安装 adapter 是装配错误,并立即失败。 + +### Store + +`client/store` 是 React-free 普通库,拥有 `ObservableSnapshot`、`SnapshotStore`、`defineStore`、`createSnapshotStore` 和 `shallowEqual`。 + +`ui-slots` 引用 store contract,`ui-renderer` 管理 store instance 并提供 `useStore`。 + +Store 只承载 draft、View selection、Chat selection、inspection request 和面板尺寸等观看或交互状态。Session、Workspace、Conversation、Remote stream 和 connection generation 不进入 Store。 + +### 注册与释放顺序 + +一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。 + +Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。 + +所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。 + +## 组合与依赖方向 + +应用 bundle 显式安装所需 Controller、adapter、target 和 renderer plugin。每个 owner 的 `apply()` 只安装自己的 service、listener 和 contribution。 + +运行时消费方向是 `session-controller → ui-session → ui-conversation → target UI`、`workspace-controller → ui-workspace` 和 `store → ui-slots → ui-renderer`;Approval 与 Question 只依赖 `ui-session` 提供的 pending 注册点。 + +图中的箭头表示运行时消费关系,不覆盖 type-only declaration merge 边。Controller 不反向依赖 UI adapter,renderer 不反向依赖领域 package,Conversation core 不依赖具体 target。 + +UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standard source 或 Slot registration,不新增聚合 facade。 + +## 开发者遵循方式 + +### 先确定数据 owner + +新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。 + +同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。 + +以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。 + +### 新增 Session-scoped 数据 + +1. 在领域 owner 中提供 React-free observable source。 +2. 在所属 UI adapter 中 declaration-merge 标准 prop 类型。 +3. 通过 `ctx.uiSession.provide()` 声明固定 roster,并从 Session binding 解析 source。 +4. 让 Slot component 从 `PropsRuntime` 获得生成的 hook,不向组件传 `ctx`。 +5. 把每个 binding 的资源清理挂到 `binding.ctx.effect()`,把 registration 清理留给 plugin fiber。 +6. 测试缺失值、重复名字、roster 替换、Session 切换和 binding disposal。 + +只有开放 key 空间使用 keyed hook;有限且稳定的 source 使用普通 hook;不会变化的标识使用 prop。不得为了减少一次注册而把业务名称硬编码进 renderer。 + +### 新增 Conversation target + +1. 在 target package 中扩展 Conversation snapshot 或 location data map。 +2. 向 `UiConversation.events` 注册所需 event Definition。 +3. 向 `UiConversation.views` 注册 snapshot builder、target id、View 与 activity 规则。 +4. 通过 `ctx.uiSession.provide()` 暴露该 target 的标准 selector hook。 +5. 在同一 package 中注册 renderer、locale 和 target-specific Slot entry。 +6. 验证 target 卸载只重建 Conversation binding,不改变 Session、其他 target 或 Remote stream。 + +Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可选协作通过窄 port 或 Slot 完成;缺失 target 时,shell 必须保持可启动且不得猜测 fallback。 + +### 新增 pending-interaction 业务 + +1. 业务 package 定义 Pending 对象及其完成、取消和中止语义。 +2. 通过 declaration merge 把对象加入 `SessionPendingInteractionMap`。 +3. 在 `apply()` 中调用 `registerPendingInteraction()` 一次,并注册唯一稳定的 composer entry。 +4. Remote waterfall listener 从 Agent Context 解析 Session;无法处理时调用 `next()`。 +5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。 +6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。 + +单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。 + +### Review 检查点 + +- 每个新 source、registry contribution、listener 和 cache 都有明确 Cordis fiber 或 Session binding owner。 +- 每个公共 hook 能追溯到唯一 React-free source;不存在只为传参而层层转发的 selector。 +- 每个 component 的数据与 action 都来自标准 props 或所属 Slot inject face。 +- 每个 target 在缺席、动态注册和卸载时都有定义明确的结果。 +- 每个跨层 import 都沿 Controller、adapter、renderer、component 的单向关系前进。 +- 每个错误由最早能解释其语义的 owner 归类;载体错误不直接泄漏成业务错误。 + +## 验证 + +各 owner 的测试分别固定 Controller binding 与 event source、UI scope 与 pending precedence、Conversation 增量组装与 View fallback、target projection、waterfall 结果以及 renderer 的 scope/store 生命周期。应用组装测试同时覆盖完整 roster 和缺少具体 target 的启动;组件测试不替代对象层、重放和生命周期测试。 + +## 备选方案 + +- **保留 Runtime facade。** 它维持单一入口,却继续形成依赖汇点并允许新代码绕过领域 owner;系统因此不保留 facade 或兼容出口。 +- **把所有 Client 状态放进 API Controller。** 这会让协议对象承担 React、View 和 presentation policy;Controller 因而只保留无 React 的领域状态。 +- **让 Controller 直接提供 React hooks。** 这会阻止非 React 消费者复用同一对象,也使 transport 与 renderer 生命周期相互依赖。 +- **把 Conversation 放进 SessionSnapshot。** 这会扩大 Session API,并迫使普通 Session 消费者理解 event folding 与 target roster。 +- **让 Chat 和 Trajectory 各自重放 Session events。** 这会重复维护顺序、location 和 registry rebuild;共享 assemble core 因而留在 `ui-conversation`。 +- **把 Conversation core 拆成额外的非 UI package。** Core 与 adapter 当前共同演化且没有其他非 UI package 消费者;同包目录隔离足以保持 React-free core。 +- **把 Workspace 与 Session 合成联合 snapshot。** 这会制造新的跨域 owner;跨域逻辑保留为 `ui-workspace` 的即时决策。 +- **让 renderer 内建所有标准 hook。** 这会要求通用基础设施认识每个领域;standard source registration 保持 renderer 与业务类型解耦。 +- **让每个 pending 请求动态注册 composer entry。** 这会重复声明 child Slot,并让并发请求竞争注册顺序;稳定 entry 与请求期对象发布保持分离。 +- **把 pending interaction 写回 Session projection。** 待回答 waterfall 不是已提交的持久 Session 事实,刷新恢复由 Remote Event replay 负责,因此它留在业务 UI source。 +- **为 binding 增加专用 release callback。** 这会重复 Cordis 生命周期;`binding.ctx.effect()` 已能把消费者清理挂到同一 owner。 +- **让 SessionProvider 通过 render function 传 Session id。** 这会产生另一条数据注入路径;普通 children 与标准 `sessionId` prop 保持 scope 数据只有一个入口。 +- **把 Store 留在 renderer。** Store contract 不依赖 React,并被对象与测试基础设施复用;独立 `client/store` 保持 engine 与渲染生命周期分离。 + +## 后果 + +Session、Workspace、Conversation 与具体 target 各自拥有一份权威状态,非 React consumer 可以直接复用 Controller 和 assemble core。新增 Conversation target 只需注册 Definition、builder、View、标准 source 和 Slot entry;新增 pending-interaction 业务只需声明类型、注册 domain 并提供稳定 composer entry。 + +Renderer 和 Session Controller 不因新增业务领域而增加分支,Session binding 与 plugin fiber 则提供两条明确且可组合的释放路径。UI 可以观察到 Session 与 Conversation source 的独立发布,消费者不得依赖二者的通知顺序。 + +组合包必须显式装载所需 adapter 与 target plugin。缺失具体 target 时 shell 仍可运行,但不会生成或猜测该 target 的 View。更多 package 和显式注册增加了装配工作,但依赖方向、测试范围与故障 owner 均可局部确定。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml new file mode 100644 index 0000000000..83f673d68e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.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/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md +2026-08-20-preview-cloudflare-pages-deploy.md: 38b2834d612153d235d3b0aaffead3bd2b33eec7 +2026-08-20-preview-cloudflare-pages-deploy.zh.md: 3ebb8eba95666729ee1021ffe2c958dad40aed1c diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md new file mode 100644 index 0000000000..38b2834d61 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md @@ -0,0 +1,27 @@ +# Agent Note: per-PR preview deployments on Cloudflare Pages + +Status: implemented + +English | [中文](2026-08-20-preview-cloudflare-pages-deploy.zh.md) + +## Problem + +The browser worker preview exists to observe a pull request's frontend and host code running, so it needs a static host per pull request that outsiders cannot reach. GitHub Pages publishes privately only on GitHub Enterprise Cloud, which this organization has not settled, and one Pages site per repository cannot isolate pull requests. The first deployment run also exposed a packaging defect: on a clean checkout `pnpm install` never creates the `dsh-pack-vfs-image` bin link, so `build:preview` fails with `command not found` anywhere but a working tree whose install ran after a build. + +## Decision + +**Deployment.** Every push to a pull request publishes `apps/web/dist` to the Cloudflare Pages project `dsh-build-preview` under the branch alias `pr-`, behind Cloudflare Access (`.github/workflows/build-preview-cloudflare.yml`). The upload carries build products only — the platform never holds repository sources, and sourcemaps are deleted before upload because they embed complete sources. `preview.html` replaces `index.html` as the deployment root: the served page cannot boot without a host injecting `window.__DSH_BOOT__`, so the root must be the page that boots. Per pull request the newest build wins; across pull requests each alias is its own URL, so nothing contends. The run passes only after a service-token request proves the protected URL serves the packed image: HTTP 200 (Access admitted the token; 302 means the Access policy lacks its Service Auth rule), no `content-encoding` (the platform must not claim transport compression over an already-compressed body, which would leave the worker's `DecompressionStream` inflating a plain tar), and the gzip magic `1f 8b`. A marker-guarded comment states the stable alias URL once per pull request. + +**Bin link.** pnpm creates a workspace bin link only when the link target exists at install time. A `bin` entry naming a build product (`lib/bin.js`) therefore never gets its link on a clean checkout — building later does not revisit linking. The packer commits a root `bin.js` as the stable link target; it forwards to `lib/bin.js` and, when the build product is missing, names `pnpm run build` and exits 1. Same pattern as `dsh-subprocess-local`'s committed spawn-helper entry. + +## Alternatives considered + +**GitHub Pages, privately published.** Enterprise-Cloud-only, and `deploy-pages` replaces the whole site, so pull requests would overwrite each other; per-branch subdirectories require the legacy branch-deploy path and its build-rate limits. + +**Actions artifact as the preview.** Download permission aligns exactly with repository read access and costs nothing, but an artifact is a zip download, not a browsable site. Kept as the fallback if the Cloudflare surface goes away. + +**Documenting "install again after building" instead of committing a link target.** Leaves every clean checkout broken in an order-dependent way the error message does not explain; CI is precisely such a checkout on every run. + +## Consequences + +A pull request's preview lives at `https://pr-.dsh-build-preview.pages.dev` and demands a Cloudflare Access sign-in; automation reaches it with a service token. The deployment platform holds no sources and no sourcemaps, which also means the preview cannot map its bundles back to source until sourcemap handling is designed deliberately. The image byte path — bytes stored compressed, served without transport re-encoding — is asserted on every deployment, so a platform behavior change fails the run instead of the worker boot. The packer bin works from any clean checkout after one full build, and the constraints table pins `bin.js` in the published file list. diff --git a/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md new file mode 100644 index 0000000000..3ebb8eba95 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md @@ -0,0 +1,27 @@ +# Agent Note:每 PR 预览部署上 Cloudflare Pages + +状态:已实现 + +[English](2026-08-20-preview-cloudflare-pages-deploy.md) | 中文 + +## 问题 + +浏览器 worker 预览的存在意义是观察某个 pull request 的前端与 host 代码运行态,因此需要一个外人无法访问的、按 pull request 隔离的静态托管。GitHub Pages 的私有发布只在 GitHub Enterprise Cloud 上可用,而本组织尚未定夺;且一个仓库一个 Pages 站点无法隔离多个 pull request。首次部署运行还暴露了一个打包缺陷:干净 checkout 上 `pnpm install` 永远不会创建 `dsh-pack-vfs-image` 的 bin 链接,`build:preview` 在任何「install 不是在 build 之后跑的」工作树上都以 `command not found` 失败。 + +## 决定 + +**部署。**pull request 的每次推送把 `apps/web/dist` 发布到 Cloudflare Pages 项目 `dsh-build-preview` 的分支别名 `pr-` 下,置于 Cloudflare Access 之后(`.github/workflows/build-preview-cloudflare.yml`)。上传只携带构建产物——平台永远拿不到仓库源码,sourcemap 因内嵌完整源码在上传前删除。`preview.html` 顶替 `index.html` 成为部署根:served 页面没有 host 注入 `window.__DSH_BOOT__` 就无法启动,所以根必须是能启动的那张页。同一 pull request 内最新构建胜出;不同 pull request 各占各的别名 URL,互不争抢。运行只有在 service token 请求证明受保护 URL 真的送达打包镜像后才算通过:HTTP 200(Access 放行了该 token;302 意味着 Access 策略缺 Service Auth 规则)、无 `content-encoding`(平台不得对已压缩的 body 声明传输压缩,否则 worker 的 `DecompressionStream` 会对着解开的裸 tar 充气)、gzip 魔数 `1f 8b`。带标记守卫的评论对每个 pull request 只报一次稳定别名 URL。 + +**bin 链接。**pnpm 只在链接目标于 install 时已存在的情况下创建 workspace bin 链接。`bin` 指向构建产物(`lib/bin.js`)因此在干净 checkout 上永远得不到链接——事后构建不会补建链接。packer 在包根提交 `bin.js` 作为稳定链接目标;它转发到 `lib/bin.js`,构建产物缺失时点名 `pnpm run build` 并以 1 退出。与 `dsh-subprocess-local` 提交 spawn-helper 入口是同一模式。 + +## 曾考虑的替代方案 + +**GitHub Pages 私有发布。**Enterprise Cloud 独占,且 `deploy-pages` 整站替换,多个 pull request 会互相覆盖;按分支子目录要走遗留的分支部署通道并吃其构建频率限制。 + +**用 Actions artifact 当预览。**下载权限与仓库 read 权限逐字对齐、零成本,但 artifact 是 zip 下载不是可浏览的站点。留作 Cloudflare 面失效时的兜底。 + +**用「build 之后再 install 一次」的文档说明代替提交链接目标。**让每个干净 checkout 都以一种错误信息解释不了的、依赖顺序的方式坏掉;CI 每次运行恰恰就是这样的 checkout。 + +## 后果 + +pull request 的预览位于 `https://pr-.dsh-build-preview.pages.dev`,访问要求 Cloudflare Access 登录;自动化用 service token 通行。部署平台不持有源码与 sourcemap,这也意味着在 sourcemap 处理被专门设计之前,预览无法把 bundle 映射回源码。镜像的字节通路——压缩存储、无传输再编码送达——在每次部署时被断言,平台行为变化会让运行失败而不是让 worker 启动失败。packer bin 在任何干净 checkout 上一次完整构建后即可用,constraints 表把 `bin.js` 钉进发布文件清单。 diff --git a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml similarity index 59% rename from .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml index 9a76fb2d7b..bf06cd8dee 100644 --- a/.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md -2026-08-19-knip-config-cleanup.md: 629e64be797bdc1c92fba5e11670118b1aa54374 -2026-08-19-knip-config-cleanup.zh.md: e7a1ec91210eac8e66d1ff1c20f41d7d0211199f +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md +2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81 +2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md new file mode 100644 index 0000000000..41a30dedc7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md @@ -0,0 +1,34 @@ +# Agent Note: the worker's Node face — builtins, VFS, and the shell process layer + +Status: implemented + +English | [中文](2026-08-20-webworker-node-face.zh.md) + +## Problem + +The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool. A structural `node:child_process` stub would let that tool mount and advertise itself to the model while every call fails. + +## Decision + +**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/.ts` carries real semantics over a worker data source; `./mock/.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly. + +**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning. + +**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. Ordinary commands resolve from that table; native-package protocols may contribute Worker-owned virtual executable wrappers through the [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md). A name in neither set reports `ENOENT` at direct spawn or `command not found` (127) inside shell source. Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub. + +## Alternatives considered + +**Replacing `dsh-subprocess-local` or the bash executor.** The first would let the proxy table replace a workspace package against its own classification and invert the layering; the second trips `dsh-permission-presets`' boot-time `sandboxMode` validation and drops tested timeout/output behavior. + +**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); this deployment excludes WASM and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused. + +**`SharedArrayBuffer` + `Atomics.wait` for a synchronous child filesystem.** Measured on the deployment target: without COOP/COEP headers `SharedArrayBuffer` is not defined, and GitHub Pages cannot set response headers. The asynchronous face is a superset; a SAB backend can slot under it later without touching a program. + +**Fabricating stats or widening error predicates instead of honoring `bigint`.** Constant `ino`/wall-clock `mtimeNs` silently disable the stale-write guard; swallowing `FS_IO_ERROR` in skill discovery would have made the same bug a permanently empty catalog with no failure anywhere. + +## Consequences + +- `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world. +- The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`. +- Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts. +- The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md new file mode 100644 index 0000000000..b574813358 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md @@ -0,0 +1,34 @@ +# Agent Note:worker 的 Node 面——builtin、VFS 与 shell 进程层 + +状态:已实施 + +[English](2026-08-20-webworker-node-face.md) | 中文 + +## 问题 + +worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层。如果 `node:child_process` 只是结构桩,工具仍会照常挂载并向模型自我宣告,但每次调用都会失败。 + +## 决定 + +**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/.ts` 在 worker 数据源之上承载真语义;`./mock/.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。 + +**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。 + +**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。普通命令从该表解析;native 包协议可以通过 [watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)提供 Worker 自有的虚拟 executable wrapper。两处都没有的名字在直接 spawn 时报告 `ENOENT`,在 shell source 中则报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。 + +## 曾考虑的替代方案 + +**整包替换 `dsh-subprocess-local` 或替换 bash 执行器。** 前者让代理表首次替换 workspace 包、违背其自身分类并倒置分层;后者撞上 `dsh-permission-presets` 对 `sandboxMode` 的 boot 期硬校验,并丢掉执行器已被测试钉住的超时/输出行为。 + +**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);本部署排除 WASM,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。 + +**`SharedArrayBuffer` + `Atomics.wait` 给子进程同步文件系统。** 在部署目标实测:无 COOP/COEP 头时 `SharedArrayBuffer` 未定义,而 GitHub Pages 无法设置响应头。异步面是超集;SAB 后端将来可垫入其下而不动任何程序。 + +**伪造 stats 或放宽错误谓词,而非如实实现 `bigint`。** 常量 `ino`/纯挂钟 `mtimeNs` 会静默废掉 stale-write guard;让技能发现吞下 `FS_IO_ERROR` 则会把同一个 bug 变成处处无失败的永久空目录。 + +## 后果 + +- `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。 +- Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。 +- 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。 +- 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml new file mode 100644 index 0000000000..b4cef0f471 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.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/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md +2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692 +2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580 diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md new file mode 100644 index 0000000000..72a6ccf856 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md @@ -0,0 +1,44 @@ +# Agent Note: pack-time lowering and the single-build preview + +Status: implemented + +English | [中文](2026-08-20-webworker-pack-lowering-and-preview.zh.md) + +## Problem + +The browser worker can neither compile modules at load nor be served by the product webserver: every module body must arrive runnable, and the page must be a static artifact. Both surfaces drifted early. The loader carried a fallback compiler, so a collector gap surfaced as a slow boot instead of a broken image — and `acorn` rode into `lib/worker.js` through the package barrel, a parser a runtime that only wraps pre-lowered bodies never needs. The preview was a second HTML template beside the served one, a page the served index could silently drift away from. + +## Decision + +**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in. + +**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `` at serve time, keeping the on-disk pages byte-shared. + +**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree. + +Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`. + +## Alternatives considered + +**A load-time transform as a safety net.** It turned a broken image into a timing regression nobody attributed, and made "which path lowered this body" unanswerable from outside. + +**Contract constants inside the transform, trusting tree shaking.** The transform functions did shake out, but `acorn` declares no `sideEffects`, so the barrel edge alone carried the whole parser into the worker bundle. + +**A separate preview template.** The retired `preview.html` template duplicated the served document and drifted (language, title, entry wiring). Deriving the page from the built index at `closeBundle` removes the second document entirely. + +**Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering. + +**Generate example state in the Worker at startup.** A preview-only Session or Workspace creation branch would bypass cold persistence loading and make the runtime own test data. Static image files exercise the same discovery and pagination path as existing user data. + +**Seed the example through WebFS.** WebFS owns user-selected durable storage and its lifecycle. Coupling the built-in demonstration to it would make a static preview depend on browser persistence state and would blur which bytes came from the deployment. + +**Pack one complete base image per fixture.** Full-image variants duplicate the runtime package closure and make combinations quadratic. Restricted overlays keep one immutable base, let the chooser compose zero or more data sources, and give future WebFS hydration the same pre-boot application point. + +## Consequences + +- `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed). +- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts. +- The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not. +- The served `` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base. +- The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads. +- The preview waits at a pre-boot source chooser. Its built-in example opens a reproducible Workspace and cold Session corpus suitable for inspecting tool cards, subagent navigation, and backward pagination without credentials or model calls; the empty selection preserves first-run coverage. Fixture tests validate the physical logs through production readers, and browser acceptance verifies both selections. diff --git a/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md new file mode 100644 index 0000000000..074d44833c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md @@ -0,0 +1,44 @@ +# Agent Note:pack 期 lowering 与单构建 preview + +状态:已实施 + +[English](2026-08-20-webworker-pack-lowering-and-preview.md) | 中文 + +## 问题 + +浏览器 worker 既不能在装载期编译模块,也不能由产品 webserver 提供页面:每个模块体必须以可直接运行的形态到达,页面必须是静态产物。两个面早期都发生过漂移。装载器曾携带一个兜底编译器,于是收集器的缺口表现为「启动变慢」而不是「镜像坏了」——而且 `acorn` 经包 barrel 混进了 `lib/worker.js`,一个只包装预 lowered 模块体的运行时根本不需要解析器。preview 曾是服务页面旁的第二份 HTML 模板,一个 served index 可以悄悄漂离的页面。 + +## 决定 + +**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。 + +**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。 + +**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。 + +两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。 + +## 曾考虑的替代方案 + +**保留装载期转换器作安全网。** 它把坏镜像变成无人归因的耗时回归,并且让「这个模块体是谁 lower 的」从外部不可回答。 + +**契约常量留在转换器里,信任 tree shaking。** 转换函数确实被摇掉了,但 `acorn` 未声明 `sideEffects`,仅 barrel 一条边就把整个解析器带进了 worker bundle。 + +**独立的 preview 模板。** 已退役的 `preview.html` 模板复制了服务文档并发生漂移(语言、标题、入口接线)。在 `closeBundle` 从 built index 派生页面则彻底消灭了第二份文档。 + +**用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。 + +**在 Worker 启动时生成示例状态。** Preview 专用的 Session 或 Workspace 创建分支会绕过冷 persistence 读取,还会让 runtime 拥有测试数据。静态镜像文件与既有用户数据经过相同的发现和分页路径。 + +**通过 WebFS 注入示例。** WebFS 拥有用户选定的 durable storage 及其生命周期。让内置演示依赖它,会使静态 preview 受浏览器持久化状态影响,并模糊哪些字节来自部署。 + +**每套 fixture 各打一份完整基础镜像。** 完整镜像变体会重复 runtime package closure,并使组合数量平方增长。受限 overlay 只保留一个不可变基础镜像,选择面板可组合零到多个数据源,未来 WebFS 水合也能复用同一个 pre-boot 应用点。 + +## 后果 + +- `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。 +- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。 +- 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。 +- served 的 `` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。 +- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。 +- Preview 会停在 pre-boot 来源选择面板。内置示例提供可复现的 Workspace 与冷 Session 语料,无凭据、零模型调用即可检查工具卡、subagent 导航和向前分页;空白选项保留首次启动覆盖。Fixture 测试通过生产 reader 校验物理日志,浏览器验收同时验证两种选择。 diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml new file mode 100644 index 0000000000..d7990f37fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.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/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md +2026-08-21-deepseek-llm-api-request-extensions.md: 018b93115f5376affd86a4da3c76f0f367ba9ed0 +2026-08-21-deepseek-llm-api-request-extensions.zh.md: 4bc0f0c992445c5897069b68efa47fdba46dfdb4 diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md new file mode 100644 index 0000000000..018b93115f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md @@ -0,0 +1,92 @@ +# Agent Note: DeepSeek LLM API request extensions for session logs and plugin packages + +Status: implemented + +English | [中文](2026-08-21-deepseek-llm-api-request-extensions.zh.md) + +## Problem + +The canonical Session log contains request boundaries, raw response chunks, assembled messages, tool activity, plugin events, and failure facts that the model message list does not preserve. The OTel session-telemetry path projects and batches that log independently of model requests, uses deployment-selected sharing modes, and intentionally drops most assistant chunks. DeepSeek's official API therefore cannot reconstruct the complete harness trajectory from its ordinary request messages or the telemetry feed. + +Provider-side diagnosis also needs the exact active plugin package versions that produced a request. The existing browser-facing plugin inventory reports configured Loader rows and lifecycle phases but owns neither package-manifest resolution nor the requesting agent's standing preset composition. + +Both values belong only on the official DeepSeek adapter path. Adding them to `GenerateOptions` or the provider-neutral LLM seam would expose DeepSeek wire concepts to pi-ai and every future adapter. + +## Decision + +`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: package metadata is enabled by default, while Session-log upload is disabled by default and requires `session-log-deepseek.enabled: true`. Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes. + +The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call. + +## Incremental session-log field + +`@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log` as an explicit opt-in. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history. + +The failure direction is at least once. A transport or provider rejection records no watermark. A crash after remote acceptance but before the watermark persists causes replay after resume, never a skipped sequence. Existing session checkpoints persist the event; the upload plugin owns no second store. + +The `events` array contains complete canonical `SessionEvent` objects directly. The sender copies every present event member without projection or redaction; the field is self-contained and requires no reconstruction against `messages`. + +## Plugin package field + +`@deepseek-ai/dsh-plugin-package-inventory-deepseek` owns the default-on `dsh_plugin_packages` field from the `llm` package family. It reads active non-group entries from the host Loader tree and, for a live requesting Agent, its standing preset tree. Node package resolution locates the owning manifest without requiring a `./package.json` export. Ordinary entries resolve from their owning tree, while a standing preset root mirrors its Loader's intentional harness-base override and nested includes retain their own bases. An anonymous nearest manifest marks a loose module; a named manifest must carry a version. Exact name/version pairs are deduplicated with deterministic ordering; simultaneously active versions remain separate. + +Disabled, pending, failed, unloading, disposed, structural, loose non-package, ordinary dependency, programmatic child-fiber, and in-memory dynamic-plugin entries are outside this package inventory. This definition reports package-backed composition facts the runtime can prove instead of inventing provenance for arbitrary callbacks. + +## Deferred inventory caching + +The implementation deliberately recalculates the active package set for every request while caching manifest identities for the process lifetime. A synthetic host-only benchmark on Node v24.16.0, macOS arm64 used unique active relative plugin packages, 20 warm-up requests, then 500 measured requests for 25 and 100 entries and 250 for 500 entries. “First request” includes uncached manifest reads; “cached-provider median” returns a prebuilt field through the same registry, so it retains `structuredClone()` and freeze costs but excludes adapter JSON serialization and network time. + +| Active entries | First request | Current warm median | Current warm p95 | Cached-provider median | +|---:|---:|---:|---:|---:| +| 25 | 1.23 ms | 0.05 ms | 0.07 ms | 0.02 ms | +| 100 | 2.23 ms | 0.14 ms | 0.24 ms | 0.04 ms | +| 500 | 10.22 ms | 0.60 ms | 0.79 ms | 0.18 ms | + +These measurements keep the cache deferred: even 500 entries stay below one millisecond at steady state, and the estimated saving is about 0.42 ms before unavoidable JSON serialization. A real profile showing material `prepare()` latency is the trigger to add the cache rather than a fixed entry-count threshold. + +The deferred design uses one monotonic inventory epoch. A global `internal/status` listener advances it whenever a Loader entry's root fiber crosses the `FiberState.ACTIVE` boundary, covering dependency activation, disablement, unload, and HMR without a time-based stale window. The contributor caches the Host snapshot by epoch, caches each standing preset `EntryTree` in a `WeakMap`, and caches the combined Host-plus-preset result by tree and epoch. Already-sorted snapshots merge and deduplicate exact `(name, version)` pairs in linear time. A calculation whose epoch changes before settlement retries instead of publishing a stale snapshot; disposed preset trees remain collectible through the `WeakMap`. + +The process-lifetime manifest-identity cache remains separate because in-process package-version replacement is not supported. + +## Verification + +Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin the default-off policy, explicit full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, out-of-order acceptance, and late invariant loading. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata plus opt-in Session upload, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests. + +## Alternatives considered + +**Add generic metadata to `GenerateOptions` or `ctx.llm`.** Rejected because the values and acceptance timing are DeepSeek wire semantics; a provider-neutral request would make every adapter understand or ignore foreign fields. + +**Hard-wire the two producers into `llm-deepseek`.** Rejected because the adapter would import Session, Loader, preset, package-manifest, and cursor logic. The registry keeps transport responsible only for field merge and HTTP acceptance. + +### Why not request-relative message references? + +A recursive tagged representation could replace exact event-string ranges with paths and UTF-8 byte offsets into the containing request's `messages`. Measurement used Node v24.16.0 on macOS arm64 and the three largest available local Zstandard Session artifacts, whose compressed artifact sizes were 2,437,052, 572,602, and 118,811 bytes. Late-enable replay used each final completed request boundary; steady replay covered 411 completed boundaries. The byte counts cover complete minified DeepSeek requests. + +| Replay | Raw JSON | Referenced JSON | Saving | Synchronous encoder time | +|---|---:|---:|---:|---:| +| Late enable | 29,668,725 B | 27,645,825 B | 6.82% | 500.1 s total | +| Steady state | 389,295,815 B | 387,180,848 B | 0.54% | 285.0 s total | + +The three late-enable calls took 470.5, 29.4, and 0.158 seconds. Only 701 of 115,071 events (0.61%) selected references. A hypothetical level-6 whole-request gzip comparison reduced raw request bytes by 89.38% for late enable and 73.42% for steady state; message references added 21.68% and 0.59% respectively after gzip. + +The receiver would also need to traverse the tagged tree, resolve paths into the exact request messages, validate UTF-8 ranges, and reconstruct every referenced event. Even treating that receiver cost as zero, the steady-state byte saving, synchronous sender cost, and dependence on another request field do not justify a versioned wire format. + +### Why not omit assistant chunks or overlapping event data? + +About 98% of the measured real-session events were `assistant/chunk`. Omitting chunks after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but it prevents lossless canonical-log reconstruction and leaves `assistant/message.sourceEventSeqs` pointing to absent events. Fuzzy or normalized substitutions have the same reconstruction defect. + +**Keep the upload cursor only in memory.** Rejected because a normal process restart would resend the entire Session. A canonical acceptance event makes restart recovery best-effort durable without another storage backend; the remaining crash window produces allowed duplicates. + +**Inventory every live Cordis fiber.** Rejected because programmatic and in-memory fibers have no authoritative npm package provenance. Loader-backed host and preset entries provide exact resolvable package identity. + +**Cache one process-global list or expire it on a TTL.** Rejected because one immutable list is incorrect for Loader lifecycle and per-Session presets, while a TTL permits stale metadata between expiry boundaries. The deferred epoch design invalidates on the authoritative active-state transition instead. + +**Replace the complete field with a content hash or server-side inventory reference.** Rejected because it changes standalone request reconstruction and requires endpoint state plus a later wire version. That is a wire-byte protocol change, not a computation-cache optimization. + +## Consequences + +Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. An explicit Session-log opt-in also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata. + +The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available. + +The [DeepSeek request-identity decision](../feature/2026-08-11-deepseek-request-user-id-header.md) continues to own user/session headers, which remain outside the body. The [session-telemetry decision](../feature/2026-07-23-session-telemetry-otel-revival.md) remains current until a separate change removes that seam and backend; this request path does not alter OTel capture or sharing modes. diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md new file mode 100644 index 0000000000..4bc0f0c992 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md @@ -0,0 +1,92 @@ +# Agent Note: DeepSeek LLM API 会话日志与插件包请求扩展 + +Status: implemented + +[English](2026-08-21-deepseek-llm-api-request-extensions.md) | 中文 + +## 问题 + +权威会话日志包含请求边界、原始响应分片、组装后消息、工具活动、插件事件与失败事实,模型消息列表无法保留全部内容。OTel 会话遥测路径独立于模型请求投影和批处理该日志,使用部署方选择的共享模式,并刻意丢弃大多数 assistant 分片。因此,DeepSeek 官方 API 无法从普通请求消息或遥测流重建完整 harness 轨迹。 + +提供方侧诊断还需要产生当前请求的确切存活插件包版本。现有面向浏览器的插件清单会报告已配置 Loader 配置项与生命周期阶段,但既不拥有包 manifest(元数据清单)解析,也不拥有请求 Agent 的 standing preset 组合。 + +两个值都只属于 DeepSeek 官方适配器路径。把它们加入 `GenerateOptions` 或提供方无关的 LLM seam,会让 pi-ai 与未来每个适配器接触 DeepSeek 协议概念。 + +## 决策 + +`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据默认开启,会话日志上传默认关闭,需要设置 `session-log-deepseek.enabled: true`。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。 + +提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。 + +## 增量会话日志字段 + +`@deepseek-ai/dsh-session-log-deepseek` 以显式选择启用的方式拥有 `dsh_session_log`。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。 + +失败方向为至少一次。传输失败或提供方拒绝不会记录水位。远端接受后、水位持久化前发生崩溃,会在恢复后触发重放,绝不会跳过序列。现有会话检查点会持久化该事件;上传插件不拥有第二份存储。 + +`events` 数组会直接包含完整的权威 `SessionEvent` 对象。发送方会复制事件的每个已有成员,不执行投影或脱敏;该字段自包含,无需根据 `messages` 重建内容。 + +## 插件包字段 + +`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 从 `llm` 包家族中拥有默认开启的 `dsh_plugin_packages` 字段。它会读取宿主 Loader 树的存活非 group 配置项,并为存活请求 Agent 读取其 standing preset 树。Node 包解析会定位所属 manifest,无需导出 `./package.json`。普通配置项从其所属树解析;standing preset 根会复现 Loader 对宿主基址的显式覆写,嵌套 include 则保留自身基址。最近的匿名 manifest 会标记松散模块;具名 manifest 必须带有版本。系统以确定性顺序按确切名称/版本对去重,同时存活的不同版本仍会分开保留。 + +禁用、pending、failed、unloading、disposed、结构性、松散非包、普通依赖、编程式子 fiber 与内存动态插件配置项都不属于该包清单。这个定义会报告运行时可以证明的包支撑组合事实,而不会为任意回调发明来源。 + +## 暂缓的清单 cache + +当前实现会为每个请求重新计算存活包集合,同时在进程生命周期内 cache manifest 身份。一项仅含宿主树的合成基准测试使用 Node v24.16.0 与 macOS arm64,测试对象为各不相同的存活相对插件包;测试先预热 20 个请求,再对 25 项和 100 项场景分别测量 500 个请求,对 500 项场景测量 250 个请求。「首次请求」包含未 cache 的 manifest 读取;「已 cache 提供方中位数」通过同一注册表返回预构建字段,因此仍包含 `structuredClone()` 与冻结开销,但不包含适配器 JSON 序列化和网络时间。 + +| 存活配置项 | 首次请求 | 当前稳态中位数 | 当前稳态 p95 | 已 cache 提供方中位数 | +|---:|---:|---:|---:|---:| +| 25 | 1.23 ms | 0.05 ms | 0.07 ms | 0.02 ms | +| 100 | 2.23 ms | 0.14 ms | 0.24 ms | 0.04 ms | +| 500 | 10.22 ms | 0.60 ms | 0.79 ms | 0.18 ms | + +这些测量结果支持继续暂缓 cache:即使存在 500 个配置项,稳态耗时仍低于 1 毫秒;在不可避免的 JSON 序列化之前,预计节省约 0.42 毫秒。加入 cache 的触发条件是真实 profile 显示 `prepare()` 延迟达到实质水平,而不是固定的配置项数量阈值。 + +暂缓设计使用一个单调递增的清单 epoch。全局 `internal/status` listener 会在 Loader 配置项的根 fiber 跨越 `FiberState.ACTIVE` 边界时推进该值,从而覆盖依赖激活、禁用、卸载与 HMR,且不会产生基于时间的陈旧窗口。贡献方按 epoch cache 宿主快照,在 `WeakMap` 中 cache 每个 standing preset `EntryTree`,并按树与 epoch cache 宿主加 preset 的合并结果。系统以线性时间合并已经排序的快照,并对确切 `(name, version)` 对去重。计算完成前 epoch 发生变化时,系统会重试而非发布陈旧快照;已 dispose 的 preset 树仍可通过 `WeakMap` 被回收。 + +进程生命周期内的 manifest 身份 cache 保持独立,因为系统不支持在进程内替换包版本。 + +## 验证 + +注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。会话测试固定默认关闭策略、显式启用后的首次完整/后续后缀交付、与基础正文消息无关的直接完整事件信封、增量水位 fold、持久化重启恢复、fork 身份围栏、乱序接受与 invariant 延迟加载。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放会固定 2xx 后扩展接受,TypeScript JSON-RPC 与 Python 打包运行时快照则通过两套 SDK 投影接受事件。真实 Loader 组合会固定默认包元数据与显式启用的会话上传,一个真实 API 请求会挂载两个随附扩展并证明官方端点接受它们;pi-ai 测试保持其协议请求不变。 + +## 考虑过的替代方案 + +**向 `GenerateOptions` 或 `ctx.llm` 添加通用元数据。** 已否决,因为这些值与接受时点属于 DeepSeek 协议语义;提供方无关请求会迫使每个适配器理解或忽略外来字段。 + +**把两个提供方硬编码进 `llm-deepseek`。** 已否决,因为适配器将导入会话、Loader、preset、包 manifest 与游标逻辑。注册表让传输只负责字段合并与 HTTP 接受。 + +### 为什么不使用请求相对消息引用? + +一种递归的带标签表示可以用所属请求 `messages` 中的路径与 UTF-8 字节偏移,替换事件字符串的确切范围。测量使用 Node v24.16.0、macOS arm64 与可用的三份最大本地 Zstandard 会话产物;其压缩产物大小分别为 2,437,052、572,602 与 118,811 字节。延迟启用回放使用各会话最后一个已完成请求边界;稳态回放覆盖 411 个已完成边界。字节数覆盖完整且最小化的 DeepSeek 请求。 + +| 回放方式 | 原始 JSON | 引用 JSON | 节省比例 | 同步编码器耗时 | +|---|---:|---:|---:|---:| +| 延迟启用 | 29,668,725 B | 27,645,825 B | 6.82% | 合计 500.1 s | +| 稳态 | 389,295,815 B | 387,180,848 B | 0.54% | 合计 285.0 s | + +三次延迟启用调用分别耗时 470.5、29.4 与 0.158 秒。115,071 个事件中只有 701 个(0.61%)选择引用。一项假设采用 level-6 整请求 gzip 的对照,使原始请求字节在延迟启用场景减少 89.38%,在稳态场景减少 73.42%;加入消息引用后,gzip 结果分别额外减少 21.68% 与 0.59%。 + +接收方还需要遍历带标签树、解析通向确切请求消息的路径、校验 UTF-8 范围,并重建每个引用事件。即使把接收方成本视为零,稳态字节节省、发送方同步成本以及对另一请求字段的依赖,也不足以支撑带版本的协议格式。 + +### 为什么不省略 assistant 分片或重叠事件数据? + +实测真实会话事件中约 98% 为 `assistant/chunk`。在引用编码后省略分片,会让完整未压缩 JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但这会阻止权威日志的无损重建,并让 `assistant/message.sourceEventSeqs` 指向缺失事件。模糊替换或规范化替换也存在同一重建缺陷。 + +**只在内存中保留上传游标。** 已否决,因为普通进程重启会重发完整会话。权威接受事件让重启恢复获得尽力而为的持久性,无需另一存储后端;剩余崩溃窗口只会产生允许的重复。 + +**清点每个存活 Cordis fiber。** 已否决,因为编程式与内存 fiber 没有权威 NPM 包来源。Loader 支撑的宿主与 preset 配置项能提供可精确解析的包身份。 + +**cache 一份全进程清单,或按 TTL 使其过期。** 已否决,因为单份不可变清单无法正确反映 Loader 生命周期与逐会话 preset,TTL 则允许元数据在过期边界之间保持陈旧。暂缓的 epoch 设计会根据权威存活状态转换执行失效。 + +**用内容 hash 或服务端清单引用替换完整字段。** 已否决,因为它会改变独立请求的重建方式,需要端点状态与后续协议版本。这属于请求字节协议变更,而不是计算 cache 优化。 + +## 后果 + +DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。显式选择启用会话日志后,请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。 + +`delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。 + +[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)继续拥有 user/session header,且这些 header 仍位于正文之外。[会话遥测决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md)在另一项变更删除该 seam 与后端之前仍保持当前有效;本请求路径不改变 OTel 捕获或共享模式。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml new file mode 100644 index 0000000000..1e9ba16226 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +2026-08-22-single-dsh-application-launcher.md: 69188e806d9192d230d1f1b52daf27a3b30481be +2026-08-22-single-dsh-application-launcher.zh.md: a6bb1909b019ea0d386bd2f4a1bb8f5199ef1974 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md new file mode 100644 index 0000000000..69188e806d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -0,0 +1,101 @@ +# Agent Note: One dsh launcher for application profiles + +Status: implemented + +English | [中文](2026-08-22-single-dsh-application-launcher.zh.md) + +## Problem + +DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model. + +The Python SDK distributes a native executable and three platform wheels whose embedded direct-config runtime cannot change launch architecture without rebuilding and validating the complete VFS closure. That distribution needs an explicit temporary exception, not a second general Node application pattern. + +## Decision + +### Launch scope + +Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. + +Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point. + +### Profile applications + +`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior. + +Profile manifests own patch reload: + +| Profile | `patchReload` | +|---|---| +| `web` | `live` | +| `headless` | `startup` | +| `sdk` | `startup` | +| `acp` | `startup` | + +Custom profiles default to `live`. A startup profile still applies its bundle, profile, home-level, and invocation `--patch` layers, but it does not watch them after boot. `dsh-base` inserts the module-HMR row disabled; a profile with a tested source-module reload lifecycle must enable it explicitly. None of the shipped profiles enable server module HMR: `patchReload: live` uses the launcher's config-only watcher while the startup profiles install no watcher. SDK and ACP cannot safely replace their server, agents, persistence, or tool registry inside one owned stdio connection. + +The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Model-visible tool and persistence defaults come from `dsh-base`, and runnable snapshots own those assembled application outputs. + +### TypeScript SDK customization + +`@deepseek-ai/dsh-sdk-client` depends on the same-version `@deepseek-ai/dsh` package, resolves its installed CLI module, runs it through the current Node executable, and selects `sdk` by default. Both client layers expose `dshBin`, `profile`, ordered `patches`, `dshHome`, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. + +SDK users customize plugins through profiles. `dsh plugin --profile ...` manages persistent dependencies and bundle order, the profile's `cordis.patch.yml` owns persistent row changes, and launch `patches` supply ordered ephemeral overrides. A custom profile must retain `@deepseek-ai/dsh-sdk-app` or another SDK server row. Relative CLI-module, patch, explicit home, and process-cwd paths become absolute before spawn, and initialization has a finite bound whose diagnostic names the selected profile. + +Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inherited `DSH_HOME`, then `~/.dsh`. `subagent-dsh-sdk` instead requires an explicit absolute home, so a nested runtime cannot discover a person's profiles, installed plugins, credentials, or sessions through the operating-system home. DSH-specific ACP child examples also pass an isolated home; the ACP backend itself remains generic for non-DSH agents. + +### Python exception and names + +The Python SDK's direct-config application lives in the private `packages/sdk/python-runtime` package named `@deepseek-ai/dsh-sdk-python-runtime`. Its only packaged executable entry is `lib/packaged-bin.js`, consumed by the private `dsh-sdk-python-runtime-closure` deploy root. It has no public npm bin. The runnable direct Python example is `examples/python-sdk-agent`. + +Python-observable behavior remains fixed: Python API, SDK wire, default `cordis.yml`, environment variables, wheel distribution names, packaged executable names, sidecar names, explicit runtime options, zero-config behavior, and supported platforms. The stable SDK family remains `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, `@deepseek-ai/dsh-sdk-jsonrpc-server`, and wire identity `deepseek-harness-sdk-runtime`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. + +### Enforcement + +`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, test support, and the private Python carrier. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates. + +## Deferred Python migration + +The Python runtime follow-up must move the packaged process through `dsh --profile sdk`, preserve the wheel's closed dependency and native sidecar behavior, and delete `@deepseek-ai/dsh-sdk-python-runtime`. Only after those conditions pass on Linux x64, Linux arm64, and macOS arm64 does the executable family change from `dsh-jsonrpc-agent-pkg--` to `deepseek-harness-sdk-runtime--`. The temporary carrier and current artifact names make that obligation visible without weakening current Python compatibility. + +## Existing decisions and supersession + +This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging. + +The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. No active note is fully superseded or eligible for archival. + +## Alternatives considered + +**Keep direct bins and state that profiles are preferred.** Rejected: documentation cannot make profiles own plugin installation, environment loading, shutdown, and tests while a supported executable bypasses them. + +**Keep forwarding compatibility bins.** Rejected: a forwarding executable remains another public launch name and compatibility promise. The pre-release repository can move callers directly to profiles. + +**Put complete standalone Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. `dsh-base` plus thin app bundles gives shared policy one owner while retaining protocol-specific negative guarantees. + +**Accept inline plugins or a complete `cordis.yml` in the TypeScript constructor.** Rejected: the SDK would become another package installer and application composer. Named profiles and patch files already provide persistent and per-launch customization through one resolution model. + +**Resolve `dsh` only from `PATH`.** Rejected: ordinary Node processes do not reliably inherit a project-local `.bin` path. A same-version package dependency provides a deterministic runtime. + +**Enable module HMR in `dsh-base` and make unsafe profiles disable it.** Rejected: the shared base also underlies custom profiles, so an enabled default makes every new application remember to opt out of source-module replacement. A disabled base makes module HMR an explicit profile capability while leaving `patchReload: live` config watching available. + +**Hot-reload protocol profiles.** Rejected: replacing a protocol server or its dependencies can invalidate pending frames and SDK-owned agents. Process restart is the adoption boundary for SDK and ACP configuration changes. + +**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, three platform wheels, ripgrep and spawn-helper sidecars, default config discovery, and clean-install behavior require their own migration evidence. + +## Verification + +- Source and built CLI acceptance cover `sdk` and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal. +- Bundle configuration tests pin module HMR disabled in `dsh-base` and absent from shipped mode overrides; the custom live-profile e2e pins config reload through the launcher's watch-only fallback. +- Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources. +- Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime. +- The real-API workflow caps file parallelism at four because one profile e2e file can own several complete `dsh` subprocess trees; workflow tests pin that resource bound. +- The Python suite exercises exe and node carriers; all packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes retain the existing artifact names. +- `verify-application-entrypoints` includes invalid fixtures for package bins, executable sources, package-launching demo wrappers, and unclassified demos. + +## Consequences + +- A user changes an SDK application's plugin composition through a named profile and ordered patches, using the same installation and resolution model as every other dsh application. +- A custom profile receives live config watching without server module HMR and opts into source-module replacement only through an explicit row override. +- SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly. +- Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime. +- Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition. +- Python keeps a visibly private, narrowly allowed direct-config carrier until its platform artifact migration is independently proven. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md new file mode 100644 index 0000000000..a6bb1909b0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -0,0 +1,101 @@ +# Agent Note: 由一个 dsh 启动应用 profile + +Status: implemented + +[English](2026-08-22-single-dsh-application-launcher.md) | 中文 + +## Problem + +DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。 + +Python SDK 分发一个原生可执行文件和三个平台 wheel 包;其中嵌入的直读配置运行时只有在重建并验证完整 VFS 闭包后才能改变启动架构。该分发需要一个明确的临时例外,而不是另一种通用 Node 应用模式。 + +## Decision + +### 启动范围 + +所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 + +Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。 + +### Profile 应用 + +`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。 + +Profile manifest 负责 patch 重载: + +| Profile | `patchReload` | +|---|---| +| `web` | `live` | +| `headless` | `startup` | +| `sdk` | `startup` | +| `acp` | `startup` | + +自定义 profile 默认为 `live`。`startup` profile 仍会应用组合包、profile、home 级与调用时 `--patch` 各层,但启动后不会监视这些文件。`dsh-base` 插入的模块 HMR(热模块替换)配置项默认禁用;具有经过验证的源码模块重载生命周期的 profile 必须显式启用它。随附 profile 均不启用服务器模块 HMR:`patchReload: live` 使用启动器的仅配置 watcher,`startup` profile 则不安装 watcher。SDK 与 ACP 无法在一个自有 stdio 连接内安全替换其服务器、agent、持久化或工具注册表。 + +随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。模型可见工具与持久化默认值来自 `dsh-base`,可运行快照负责钉住这些已组装的应用输出。 + +### TypeScript SDK 自定义 + +`@deepseek-ai/dsh-sdk-client` 依赖同版本的 `@deepseek-ai/dsh` 包,解析其已安装 CLI 模块,通过当前 Node 可执行文件运行该模块,并默认选择 `sdk`。两层客户端都暴露 `dshBin`、`profile`、有序 `patches`、`dshHome`、进程 cwd、环境和超时;任意 command/argv 启动只保留为 fake-runtime 测试的内部适配器。 + +SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管理持久依赖与组合包顺序,profile 的 `cordis.patch.yml` 负责持久配置项变更,启动时 `patches` 提供有序临时覆盖。自定义 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 SDK 服务器配置项。相对 CLI 模块、patch、显式 home 与进程 cwd 路径会在 spawn 前变为绝对路径;初始化具有有限时限,诊断会写明所选 profile。 + +直接使用 SDK 时遵循普通 Harness home 解析:显式 `dshHome`、继承的 `DSH_HOME`,最后是 `~/.dsh`。`subagent-dsh-sdk` 则要求显式绝对 home,因此嵌套运行时不会通过操作系统 home 发现个人 profile、已安装插件、凭据或会话。DSH 专用 ACP 子进程示例同样传入隔离 home;ACP 后端自身继续适用于非 DSH agent。 + +### Python 例外与命名 + +Python SDK 的直读配置应用位于私有 `packages/sdk/python-runtime` 包,名称是 `@deepseek-ai/dsh-sdk-python-runtime`。它唯一的打包可执行入口是 `lib/packaged-bin.js`,由私有 `dsh-sdk-python-runtime-closure` 部署根消费。它没有公开 npm bin。可运行的直启 Python 示例是 `examples/python-sdk-agent`。 + +Python 可观察行为保持不变:Python API、SDK 协议格式、默认 `cordis.yml`、环境变量、wheel 包分发名称、打包可执行文件名称、伴随文件名称、显式运行时选项、零配置行为与支持平台。稳定 SDK 包族继续是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol`、`@deepseek-ai/dsh-sdk-jsonrpc-server`,协议 identity 继续是 `deepseek-harness-sdk-runtime`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。 + +### 强制校验 + +`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具、测试支持以及私有 Python 载体进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。 + +## 暂缓的 Python 迁移 + +Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`,保持 wheel 包的封闭依赖与原生伴随文件行为,并删除 `@deepseek-ai/dsh-sdk-python-runtime`。只有这些条件在 Linux x64、Linux arm64 与 macOS arm64 全部通过后,可执行文件族才会从 `dsh-jsonrpc-agent-pkg--` 改名为 `deepseek-harness-sdk-runtime--`。临时载体与当前产物名称使这项义务清晰可见,同时不削弱当前 Python 兼容性。 + +## 既有决策与取代关系 + +本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)、[移除 SDK 项目工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。 + +[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 + +## 考虑过的替代方案 + +**保留直启 bin,只声明推荐 profile。** 拒绝:只要受支持的可执行文件仍然绕过 profile,文档就无法让 profile 真正负责插件安装、环境加载、关闭和测试。 + +**保留转发兼容 bin。** 拒绝:转发可执行文件仍然形成另一个公开启动名称与兼容承诺。预发布仓库可以让调用方直接迁移到 profile。 + +**把完整独立 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。`dsh-base` 加轻量应用组合包让共享策略只有一个归属,同时保留协议专属的负面保证。 + +**在 TypeScript 构造函数中接受内联插件或完整 `cordis.yml`。** 拒绝:SDK 会因此成为另一个包安装器和应用组合器。具名 profile 与 patch 文件已通过统一解析模型提供持久与逐次启动自定义。 + +**只从 `PATH` 解析 `dsh`。** 拒绝:普通 Node 进程不一定继承项目本地 `.bin` 路径。同版本包依赖可以提供确定的运行时。 + +**在 `dsh-base` 中启用模块 HMR,再由不安全的 profile 逐一禁用。** 拒绝:共享 base 同样承载自定义 profile;默认启用会要求每个新应用都记得退出源码模块替换。base 默认禁用会让模块 HMR 成为显式的 profile 能力,同时保留 `patchReload: live` 配置监视。 + +**热重载协议 profile。** 拒绝:替换协议服务器或其依赖可能破坏待处理协议帧与 SDK 自有 agent。进程重启是 SDK 与 ACP 配置变更的采用边界。 + +**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、三个平台 wheel 包、ripgrep 与 spawn-helper 伴随文件、默认配置发现和干净安装行为都需要自己的迁移证据。 + +## 验证 + +- 源码与构建后 CLI 验收覆盖 `sdk` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。 +- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不再重复该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。 +- 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。 +- 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。 +- 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。 +- Python 套件同时测试 exe 与 node 载体;全部打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试都保留既有产物名称。 +- `verify-application-entrypoints` 包含包 bin、可执行源码、直启包的 demo wrapper 与未分类 demo 等非法 fixture(测试前置数据)。 + +## 影响 + +- 用户通过具名 profile 与有序 patch 更改 SDK 应用的插件组合,使用与其他所有 dsh 应用相同的安装与解析模型。 +- 自定义 profile 可以在不启用服务器模块 HMR 的情况下获得实时配置监视,只有显式覆盖配置项才会启用源码模块替换。 +- SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。 +- 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。 +- 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。 +- Python 保留一个清晰可见的私有直读配置载体,直到其平台产物迁移得到独立证明。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml new file mode 100644 index 0000000000..dc2902d1ca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +2026-08-23-client-derived-tool-presentation.md: 5598c1fbc6073a71f63a20274cd5a791b6b5e6b3 +2026-08-23-client-derived-tool-presentation.zh.md: 5a87433377af58b1ca9d378b76393dada4ca4a1e diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md new file mode 100644 index 0000000000..5598c1fbc6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -0,0 +1,705 @@ +# Agent Note: Client-Derived Presentation from Raw Session Tool Events + +Status: implemented + +English | [中文](2026-08-23-client-derived-tool-presentation.zh.md) + +## Problem + +Session history is a durable journal interface, while tool cards are Client presentation. Computing card views during `page` or `follow` would couple history reads to the Tools registry, Agent presets, restored scopes, presenter execution, and transient UI types. + +A `tool/result` does not repeat the tool name or arguments. Host-side result presentation therefore requires either a call index or a backward scan by `callId`; repeated scans over a tool-dense page can approach quadratic work because `maxMessages` does not directly bound the event count. + +Host projection would also duplicate structured data. Read, diff, search, and web results already persist bounded facts in `tool/result.data.meta`; another view object increases Remote payload size and Client decoding without adding durable meaning. + +The Client already owns a complete tool-presentation entry point. `ui-chat` assembles `tool/call`, `tool/result`, and Code Dispatch events into stable `ToolCallBlock` values. `ui-tool` owns the recursive call tree, the `tool.call.toolview` keyed slot dispatched by tool name, the Generic fallback, card models, and details output. A business Client plugin can register a renderer for its own tool names. + +Splitting presentation between Host presenters and Client keyed renderers creates two interpretations of the same event. The keyed renderer is the Web extension point, so an intermediate Host view provides no independent Web capability. + +`ToolDefinition.presentCall` and `presentResult` remain useful Host APIs even though ACP is automation-only and the repository has no production TUI consumer. Removing their definitions is a separate decision from keeping Session reads independent of presentation. + +The required result is one raw Session journal and one Client presentation owner without visual degradation or incidental enhancement. Specialized cards, interactions, and Code Dispatch topology remain stable while the transport stops carrying transient views. + +## Decision + +The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. + +The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props. + +Client `ui-tool` continues to own card models and concrete renderers. Each card model directly reads the tool name, raw arguments, result content, error, durable metadata, Session cwd, and Host home from `ToolCallBlock`, and produces the same component props as the current page. + +The Client has no second presenter registry. Tool-name dispatch uses only the existing `tool.call.toolview` keyed slot. Pure Client card-model helpers are renderer implementation details, not a Cordis service, public registry, or wire DTO. + +The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCallView`, `ToolResultView`, and existing tool presenter implementations remain. The Session Controller does not invoke them, and the Client does not import or consume them. A future non-Client consumer is outside this decision. + +`ToolOutputDefinition.presentationMeta` and durable `tool/result.data.meta` remain. They carry execution-result facts required by existing specialized cards that the model-visible result text cannot represent losslessly. The Client validates and consumes `meta` directly rather than requiring the Host to convert it into a view during history reads. + +### Goals and non-goals + +| Category | Decision | +|---|---| +| Absent | `SessionEventEntry.view`, `SessionToolView`, and `SessionToolCallView` | +| Absent | `viewFor`, `backscanArgs`, `parseToolCall`, `jsonView`, and presenter-scope lookup from `history.ts` | +| Absent | `openCalls` and fallback event scans used only for follow presentation | +| Absent | the Client Session's parallel `views` array, Conversation input `view`, and Tool block `callView`/`resultView` | +| Derived | terminal, diff, read, search, and web card models read raw blocks and metadata | +| Derived | Deliverables reads successful mutation names and arguments | +| Retained | Host `ToolDefinition.presentCall`/`presentResult` APIs, types, implementations, and direct tests | +| Retained | `output.presentationMeta` and durable `tool/result.data.meta` | +| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | +| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | +| Forbidden | a new Client presenter service, parallel registry, or wire renderer id | +| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements | +| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | + +## Terminology + +**Raw Session event** means a `SessionEvent` fact from the durable log, including the `name` and raw `arguments` string on `tool/call`, and the `content`, `isError`, structured error, and optional `meta` on `tool/result`. + +**Durable metadata** means the JSON value produced by `ToolOutputDefinition.presentationMeta` after a tool succeeds and stored in `tool/result.data.meta`. It is part of the result facts, not a pre-laid-out React or card DTO. + +**Host tool view** means the `ToolCallView` or `ToolResultView` returned by `ToolDefinition.presentCall` or `presentResult`. Session Remote does not transport it. + +**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. + +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. + +**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. + +**No enhancement** means that this decision does not give an input pinned to Generic fallback a new specialized card or expand an existing card's data or interactions. + +## Architecture and Ownership + +### Tool execution and persistence + +1. A tool registers `output.schema`, `output.render`, and optional `output.presentationMeta`. +2. Successful execution produces a canonical JSON value. +3. The Tools runtime snapshots, schema-validates, and freezes the value. +4. `output.render(args, value)` produces model-visible `ContentBlock[]`. +5. When a top-level call declares `output.presentationMeta`, the runtime also produces JSON-safe metadata. +6. The agent loop writes the model-visible result and metadata into a `tool/result` Session event. +7. The Session log does not store `ToolCallView` or `ToolResultView`. + +### Host journal reads + +1. `session.page` obtains attached or persisted events. +2. `paginate()` cuts pages on append-origin user/assistant message boundaries. +3. A tail page obtains its baseline from the registered projection snapshot/restore path. +4. Every page entry contains only `{event}`. +5. `session.follow` establishes its listener before catch-up reads, emits the opening cursor, and then streams contiguous `{event}` frames. +6. Neither path resolves a preset or Tools scope for presentation, parses tool arguments, invokes presenters, or indexes calls. + +### Client data and presentation + +1. The Client Session stores one contiguous raw event window. +2. `SessionEventSource` publishes `SessionEventEntry` values containing only events. +3. `ui-conversation` folds each event without a presentation companion. +4. The Chat and Trajectory Tool Definitions pair top-level calls and results by callId and assemble Code Dispatch subtrees. +5. `RunningToolCall` and `ToolResultNode` retain raw facts, metadata, and existing parent identity. +6. `ToolCallTree` dispatches `tool.call.toolview` by wire tool name. +7. `ui-tool` derives card component props from the block at the render site. + +### Production consumer audit + +| Object | Producer | Production consumer | Decision | +|---|---|---|---| +| `presentCall`/`presentResult` | Host tools | non-Client callers, if any | retained outside Session Remote | +| `SessionEventEntry.view` | none | none | absent from the wire | +| `callView`/`resultView` | none | none | absent from the Client model | +| `presentationMeta` | Tools runtime | `tool/result`, Client card models, and Host presenters | retained durable input | +| fixture presenter mirror | none | none | fixtures send raw metadata | + +ACP does not consume a Session tool view or map Host render intent. The repository has no production TUI consumer. Host presenters remain available without making Session Remote their transport. + +## Data Flow + +```text +Tool execute + -> canonical value + -> output.render(args, value) + -> model-visible result content + -> output.presentationMeta(args, value), when declared + -> durable tool/result event + +Session page/follow + -> raw Session event envelope + -> no tool lookup + -> no preset lookup for presentation + -> no call backscan + -> no render-intent serialization + +Client SessionEventSource + -> Conversation Tool Definition + -> root call/result pairing + Code Dispatch topology + -> ToolCallBlock(name, argsRaw, content, error, meta) + -> tool.call.toolview keyed dispatch + -> Client card model + -> existing React component +``` + +This path retains one durable metadata projection because it runs while the canonical result is still in memory. It removes the second presentation projection performed while reading history. + +### Layer responsibilities + +| Layer | Owns | Does not own | +|---|---|---| +| Tools runtime | execution, canonical value, model text, replayable metadata | Web card selection and component props | +| Session log | durable facts, ordering, replay | transient card DTOs | +| Session Controller | addressing, authority, cold reads, pagination, follow, projection baseline | tool lookup, presenters, presentation scope | +| Client Session | Remote journal lifecycle and contiguous window | tool meaning and card types | +| Conversation Tool Definition | call/result pairing, lifecycle, root/subcall topology | mapping a tool name to a component | +| `ui-tool` | card models, Generic fallback, Chat/Details presentation | Session pagination and the Host registry | +| Business Client plugin | keyed renderer for its own tool name | root/subcall assembly and a global registry | +| `ui-deliverables` | produced paths for current first-party mutations | UI cards or Host render intent | + +## Remote and Durable Data Contracts + +### `SessionEventEntry` + +`SessionEventEntry` remains the journal-entry envelope and contains only `event: SessionWireEvent`. This change does not also turn page entries into bare events or refactor the general `RemoteJournalStream` entry contract. + +`SessionPage.events` remains `SessionEventEntry[]`. + +`SessionFollowFrame` remains either an opening frame or an event frame containing `event`. + +`SessionToolCallView`, `SessionToolView`, and `SessionEventEntry.view` are deleted. + +The Client connection stops re-exporting `ToolCallView` and `ToolResultView` from `dsh-tools/presentation` for Session consumers. + +Generated catalogs and graphs derive the narrowed Remote types and package dependencies from their owning sources. + +### Durable log + +- `tool/call.data.name` remains unchanged. +- `tool/call.data.arguments` remains the model-produced raw JSON string. +- `tool/result.data.message.content` remains the model-visible result. +- `tool/result.data.error` remains the structured failure identity. +- `tool/result.data.meta` remains a tool-private JSON value. +- Client card models do not write to the Session log. +- Renderer keys and Host tool implementation ids do not enter the Session log. +- Existing durable Sessions need no migration, and `SESSION_FORMAT_VERSION` does not change. + +### `presentationMeta` + +`presentationMeta` is not a Host tool view. It reads the canonical value when tool execution completes, and that value is not persisted. Removing it would make the following existing presentation impossible to reconstruct losslessly: + +- read path, offset, lines, totalLines, and lang; +- applied contextual hunks for write/edit; +- grouped grep/glob results, truncation flag, and total; +- web_search source fields and provider answer; +- web_fetch final URL, HTTP status, and effective truncation flag. + +The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to more neutral result metadata is outside this decision. + +## Host Design + +After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set. + +`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events. + +The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact. + +An immutable event-conversion helper may remain narrow or be inlined; its name is irrelevant as long as history performs no presentation work. + +Session Controller dependencies remain only when another package responsibility requires them. Manifest and project references contain no presentation-only dependency. + +### Performance constraints + +- `page()` performs no tool-specific work. +- Adding tool results to a page does not cause repeated scans over existing page events. +- `follow()` maintains no presentation index. +- History does not trigger the Cordis `tools` service proxy. +- History does not wait for a presenter standing scope. +- History does not parse tool-argument JSON. +- History does not perform tool-view JSON clones. +- The Remote payload does not repeat structured data already expressed by `meta`. +- The Client does not scan the complete Session event window to build one card. +- The Client derives a card model again only when the corresponding immutable Tool block changes. + +## Client Session and Conversation + +The Client Session has no private `views` array parallel to the raw event window. `installWindow()`, `prependWindow()`, and `appendLive()` handle only event entries, cursor/hasMore state, queues, projection, and notifications. + +`ConversationEventInput` contains only `event`. The Conversation assembler does not know `SessionToolView`; its replace/prepend/append behavior, Context identity, Location, and publication cadence remain unchanged. + +The Chat and Trajectory Tool Definitions read no views. They derive the following data from events: + +- callId; +- tool name; +- raw arguments; +- turn, step, seq, and time; +- result content; +- isError and structured error; +- result metadata; +- root/subcall parent-child topology; +- synthetic interruption results. + +`RunningToolCall` has no `callView`. + +`ToolResultNode` has no `callView` or `resultView`. + +`ToolCallBlock` does not gain a generic `view`, `card`, `kind`, or `locations` field to replace the deleted fields. Concrete presentation remains the responsibility of `ui-tool` and keyed renderers. + +### Root and Code Dispatch subcalls + +Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card. + +Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block. + +The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field. + +The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged. + +### Missing call head + +When a result node has no matching call in the current window, `ToolResultNode.call` remains `null`. The Client does not scan the window, issue another RPC, or infer a tool name from result text. + +A specialized derivation that needs the name or arguments uses the current Generic fallback when `call === null`. A model that could use result metadata alone does not gain new presentation, because the current Host `presentResult` must first recover the matching call. + +If a later older page supplies the call head, the Conversation Context rebuilds under existing replay rules and may then produce the already-supported specialized card. + +### Argument and metadata narrowing + +The Client parses JSON from `argsRaw`; a parse failure returns the Generic form instead of throwing a React render error. + +Chat and Details reuse parsing for the same block through pure helpers. Any future cache must use immutable block identity and must not create cross-Session global state keyed by callId. + +Each specialized model checks only the fields it needs. The Client does not copy complete Host tool schemas or invoke a Host `defineTool` validator. + +Valid first-party events must be equivalent to current presenter output. Malformed, old-version, or manually edited logs promise only a crash-free Generic fallback. + +## Client Card-Model Design + +The existing `ui-tool/src/client/tool/models/` directory remains the single source of shared derivation for Chat and Details. Helpers return component props directly; they do not return `ToolCallView` or `ToolResultView`, and they do not create an isomorphic `ClientToolView` union. + +Branches on tool name exist only in `ui-tool` card models, existing row-classification tables, or the Client plugin that owns a keyed renderer for that tool. They must not enter the Session Controller, Client Session, Conversation assembler, or generic Slot renderer. + +Unknown tools continue to use `GenericToolCard` with the name, raw arguments, result content, and error. + +### Generic tool row + +`toolRowModel()` derives the generic row directly from `toolName`, `argsRaw`, result content, error, cwd, and home. It preserves: + +- classification into `search`, `read`, `bash`, `write`, `edit`, `code`, and `others`; +- existing titles and tool-specific titles; +- summary-field priority and single-line truncation; +- comma joining of multiple queries; +- cwd-relative paths and home abbreviation; +- file-path clicks; +- pretty JSON arguments and non-JSON raw-text fallback; +- flattened result content and structured-error fallback; +- running, ok, error, and stopped states. + +The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values. + +### Terminal card + +The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd. + +| Input | Preserved result | +|---|---| +| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | +| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| `run_in_background:true` | Generic row and raw result | +| tool execution error | Generic IN/OUT and error summary | +| running persistent `bash`/`pwsh` | terminal prompt | +| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card | +| foreground `terminal_send` | terminal prompt and output | +| background/error `terminal_send` | Generic result | +| Code Dispatch child | current flattened Generic form | + +Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body. + +Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots. + +For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale. + +Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation. + +`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged. + +### Diff card + +| Input | Preserved result | +|---|---| +| running `write` | intended added-only diff from `file_path` and `content` | +| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` | +| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` | +| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` | +| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` | +| settled `str_replace_editor` | Generic, because the tool defines no result presenter | +| write create or missing/malformed/empty applied metadata | current argument fallback | +| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic | + +Paths, `oldText:null`, `newText`, result-over-call diff precedence, the eight-line Chat limit, full-height Details presentation, and file-opening behavior remain unchanged. + +### Read card + +A running `read` continues to show only the summary row. A successful settled `read` reads path, offset, lines, totalLines, and lang from result metadata and confirms that the result is one text block matching the read envelope. + +Missing metadata, malformed fields, a mismatched result envelope, an error, a missing call head, or a Code Dispatch child all use Generic. Cwd-relative path labels, home abbreviation, syntax language, total line count, the eight-line Chat limit, and full-height Details presentation remain unchanged. + +The Client does not need to construct Host `ReadResultView.content`; Generic fallback can always read raw result content directly. + +### Search card + +A running `grep` or `glob` continues to show only the argument summary. Successful results produce grouped matches or a path list from `meta.shape:'matches'` and `meta.shape:'paths'`, respectively. + +The Client validates path, lineNumber, line, truncated, and total. Empty matches or paths form a valid card. Missing or malformed metadata, an unknown shape, an error, a missing call head, or a Code Dispatch child uses Generic. + +When `truncated:true`, the card continues to show a recovery locator from raw result content. It does not show one when untruncated. The eight-line Chat limit, full-height Details presentation, and expansion behavior remain unchanged. + +### Web card + +A running `web_search` or `web_fetch` continues to show only the summary row. A successful search builds the card from `meta.sources`, `meta.answer`, and `meta.truncated`; a successful fetch builds it from `meta.url`, `meta.statusCode`, and `meta.truncated`. + +The Client validates every source's url, title, snippet, and publishedAt, and continues rendering only http/https URLs as links. Missing or malformed metadata, an error, a missing call head, or a Code Dispatch child uses Generic. + +Search answer text, source ordering, label fallback, and truncation notice remain unchanged. The fetch final URL, status, truncation notice, and raw body below Details remain unchanged. + +### Renderers already using raw blocks + +- Todo rows continue deriving completed/active summaries from arguments. +- Question rows continue deriving waiting, answered, cancelled, and interrupted states from result content and errors. +- Skill rows continue deriving names and states from calls and results. +- Cordis define/run/action rows continue deriving from calls, results, and their own Client services. +- These renderers retain their props, slot keys, registration order, and visible results. + +## Deliverables + +`ui-deliverables` derives mutation business facts independently of presentation intent, so produced-file behavior is not coupled to card screenshots. + +The Deliverables Definition observes root `tool/call` and successful `tool/result` events by callId and retains a minimal Client-owned mutation candidate without scanning the Session window or depending on a UI renderer. + +| Tool | Mutation condition | Path source | +|---|---|---| +| `write` | any successful call | `file_path` | +| `edit` | any successful call | `file_path` | +| `str_replace_editor` | `create`, `str_replace`, or `insert` | `path` | +| `str_replace_editor` | `view` | produces no path | +| Other | no current first-party mutation semantics | produces no path | + +Failures, interruptions, orphan results, missing paths, and malformed arguments produce no deliverable. Paths retain first-seen deduplication, and results settled after the closing Assistant seq remain excluded. + +This change does not add a general tool-side-effect registry. The ability for a Host-only third-party presenter to join Deliverables automatically through `kind:'edit'` or `locations` is intentionally removed. A future real third-party mutation requirement must use a Client business contribution and cannot restore Session views. + +## Fixtures and Test Data + +The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log. + +| Fixture | Raw facts that must remain | +|---|---| +| terminal | arguments and real result status markers | +| diff | arguments and result `meta.diffs` | +| read | result metadata path/offset/lines/totalLines/lang | +| grep/glob | result metadata shape/files or paths/truncated/total | +| web | result metadata sources/answer or url/statusCode/truncated | +| generic/custom | name, argsRaw, content, and error | + +The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path. + +## Presentation-Equivalence Matrix + +“Current presentation” is defined by committed component tests, assembly tests, and Web browser expected outputs. A transport or ownership refactor does not justify refreshing snapshots; an approved product change requires separate evidence. + +| Scenario | Required presentation | +|---|---| +| unknown tool, running | Generic row with tool name and argument summary | +| unknown tool, settled | Generic row and raw output | +| malformed arguments | safe Generic fallback | +| orphan result | callId title and Generic output | +| interrupted call | warning/stopped state | +| foreground bash/pwsh | current terminal prompt, body, cwd, and state | +| background/error bash/pwsh | current Generic IN/OUT | +| persistent shell | current running terminal and settled Generic form | +| terminal_send | current foreground terminal and background/error Generic form | +| write/edit | current intended/applied diff and error fallback | +| read | current running summary, settled ReadBlock, and error fallback | +| grep/glob | current grouped/path card, truncation, and recovery | +| web_search/web_fetch | current source/summary card and raw body | +| Todo/Question/Skill/Cordis | current specialized rows | +| Code Dispatch subcall | current Generic/flattened form | +| Chat and Details | identical card fields for the same call | +| Trajectory | current identity, tree, selection, and details | +| Deliverables | current successful-mutation chips and links | + +## Client Extension Contract + +`tool.call.toolview` remains the sole tool UI registration mechanism. A tool that needs specialized Client presentation must have a Client plugin register its wire tool name. + +The registrant receives the raw `ToolCallBlock`, Session path information, and host actions, and validates the argument and metadata fields it recognizes. It does not call the Host tool registry, depend on `presentCall` or `presentResult`, or require `SessionEventEntry.view`. + +A tool with no Client renderer consistently degrades to Generic. Only one keyed registration for a tool name can be active, and duplicate keys continue to fail loudly. + +A Session-scoped slot can express Client-side Session differences, but no renderer variant is inferred from a preset. A Host-only presenter does not grant a Web rich card automatically. This is the explicit boundary between “the Host describes presentation” and “the Client plugin owns presentation.” + +## Failures and Fallback + +- The Client treats arguments and metadata as wire JSON and narrows them at the consumption site. +- Argument JSON parse failure uses Generic. +- A known tool missing required fields uses Generic. +- Missing or malformed metadata uses Generic, except successful `write`, whose current presenter preserves its argument-derived whole-file diff. +- An error result does not show a success card merely because metadata is present. +- A missing call head does not trigger guesses about the tool name or arguments. +- Unknown metadata fields are ignored. +- A new metadata variant uses Generic in an older Client. +- Card-model helpers catch expected parse failures instead of relying on a React error boundary for ordinary fallback. +- Unexpected failures inside a keyed renderer remain isolated by existing Slot error handling. + +## Same-Named Host Providers + +The Host registry allows different scopes to provide different definitions under the same tool name. Through presenter scope, a Session view can theoretically select a different render intent by preset. After removing the view, the Client keyed slot observes only the wire name and cannot observe Host definition identity. + +The notable current first-party examples are standard and persistent `bash` and `pwsh`. Client derivation uses valid argument and result features to preserve their delivered differences without a provider-id wire field. Malformed or custom same-name provider input that cannot be distinguished uses Generic. + +This change does not promise to preserve differences expressed only through a Host presenter by third-party same-name providers. If the product later requires distinct Client presentation for same-name providers, it must define a stable, non-presentational Client identity and must not restore per-page Host view computation. + +## Shipped Scope + +### Session Controller + +- `SessionEventEntry` contains only the raw event. +- Both Session tool-view types are absent. +- History has no presentation imports, helpers, or page/follow presentation state. +- Addressing, pagination, follow, and projection logic remain in the Session owner. +- Host tests assert the raw journal contract. + +### Session Controller Client + +- `Session.views` is absent. +- EventSource replace/prepend/append deltas remain unchanged. +- Transport, fixture, and test-support types carry raw entries. +- Event identity and reference stability remain unchanged. + +### UI Conversation, Chat, and Trajectory + +- Conversation input and Tool blocks contain no view fields. +- Chat and Trajectory Tool Definitions read raw events. +- Event pairing, Context replay, trees, and target snapshots remain unchanged. +- Child Tool blocks preserve the existing Code Dispatch `parentCallId`; row and Details slot owner props add no separate placement field. + +### UI Tool and Deliverables + +- Card models derive from raw blocks and metadata. +- Chat and Details share the same helpers. +- Generic fallback and keyed dispatch remain unchanged. +- Deliverables recognizes first-party mutation arguments. + +### Fixtures, documentation, and generated artifacts + +- Fixtures send only raw events and metadata. +- Session Controller and Client README/JSDoc contracts describe the raw journal and Client presentation owner. +- The tool cookbook documents the Web Client integration path. +- This Agent Note is the decision owner; retained Host presenter notes keep their independent decisions. +- Authored Remote types, dependencies, READMEs, pairing records, and generated references remain synchronized. + +## Verification Matrix + +### Host + +- page returns contiguous raw event entries. +- follow returns an opening cursor and contiguous raw event entries. +- page/follow behave identically without the Tools service. +- A cold page does not resolve or mount a preset. +- A tail page computes its baseline through the standard projection registry; provider availability follows the projection composition rather than a history-side setup path. +- Addressing, ownership, message-aligned boundaries, and tail projection remain unchanged. +- Listener-before-read, reconnect catch-up, and gap repair remain unchanged. +- Many tool results do not trigger a backscan per result. +- Wire results contain no view. + +`session-history-journal.host.spec.ts` owns pagination, continuity, and history error behavior without presenter assertions. + +### Client Conversation + +- replace, prepend, and append accept entries without views. +- Chat and Trajectory root call/result pairing remains unchanged. +- The Code Dispatch tree remains unchanged. +- Result-only fallback remains unchanged. +- A synthetic interruption result copies no view. +- Node identity across registry rebuild, older prepend, and live append remains unchanged. + +### Client card model + +- terminal produces the pinned props from raw arguments/content. +- diff produces the pinned diffs from arguments/metadata. +- read produces the pinned lines from metadata/content. +- search produces the pinned grouped/path card and recovery from metadata/content. +- web produces the pinned sources/fetch summary from metadata/content. +- unknown, malformed, error, missing-call, and missing-metadata cases remain Generic. +- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants. +- Chat and Details produce identical card fields for the same block. + +### Deliverables + +- Successful write/edit calls produce `file_path`. +- str_replace_editor create/str_replace/insert calls produce `path`. +- str_replace_editor view produces no path. +- failure, interruption, malformed input, and orphan results produce no path. +- First-seen deduplication and the closing-seq cutoff remain unchanged. + +### Assembly and browser + +- terminal, diff, read, search, and web browser expected outputs all pass without refresh. +- Visible assertions for the tool tree, details, trajectory, and deliverables retain their expected values. +- The built Client still displays the same cards after obtaining raw events from real Remote page/follow operations. +- Fixtures and the real Host use the same Client derivation. +- A minimal preset independently pins persistent-shell behavior. + +### Static and documentation + +- Production code contains no `SessionToolView` or `SessionToolCallView`. +- Session history does not reference `dsh-tools/presentation`, `ctx.tools`, `presenterScopeFor`, or `backscanArgs`. +- Client Conversation does not reference `ToolCallView` or `ToolResultView`. +- Client models do not read `callView` or `resultView`. +- The fixture defines no presenter mirror. +- Host `presentCall`, `presentResult`, and `presentationMeta` remain. +- No new Client registry or Host-to-Client presentation hint exists. +- Affected authored types, READMEs, Agent Notes, catalogs, and graphs are synchronized. + +## Verification Commands + +Changes to this decision use `dsh-pre-push-checks` to select commands for the final diff. Required evidence includes: + +- focused Session Controller history/transport tests; +- ui-chat and ui-trajectory Tool Definition tests; +- ui-tool terminal, diff, read, search, web, row, tree, and details tests; +- ui-deliverables produced-file tests; +- connection fixture and Client runtime tests; +- affected Host and Client TypeScript faces; +- lint and duplication; +- per-file 100% coverage for affected source files; +- `DSH_SNAPSHOT=replay pnpm run test:web`, without refreshing existing presentation goldens; +- authored Remote type and TypeScript checks; +- `pnpm run doc-sync`; +- `git diff --check`. + +## Shipped Invariants + +- Session page/follow does not read the Tools registry or a presenter scope. +- Session history has no callId backscan, presentation cache, or view clone. +- A Remote Session entry carries no view. +- The Session log and `SESSION_FORMAT_VERSION` remain unchanged. +- Result metadata passes byte-for-byte through the log and Remote to the Client. +- Conversation assembles `ToolCallBlock` only from raw events. +- `ToolCallBlock` contains no Host render-intent fields. +- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts. +- Generic, Todo, Question, Skill, and Cordis rows remain unchanged. +- Deliverables does not depend on render intent and preserves current paths. +- Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged. +- Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely. +- Code Dispatch subcalls remain Generic and flattened. +- Chat, Details, and Trajectory behavior remains unchanged. +- Existing Web browser expected outputs pass without refresh. +- Host presenter APIs, implementations, and direct tests remain unchanged. +- ACP output remains unchanged. +- No new downstream presentation field or second Client registry is introduced. +- Pagination cost no longer grows as the number of results multiplied by page event count. +- Downstream payloads no longer duplicate result metadata in a card DTO. + +## Alternatives considered + +### Optimize only `backscanArgs` and retain views + +Building one `callId → {name,args}` Map before processing a page would make backscan linear, and live follow already has an `openCalls` fast path. It would leave Host lookups, preset scopes, presenters, JSON clones, duplicate payloads, and dual ownership intact, so this alternative is rejected. + +### Add a presenter registry to the Client + +Copying the `presentCall` and `presentResult` interfaces into the browser would duplicate the registration, lifecycle, fallback, and override semantics of the `tool.call.toolview` slot. Renderers would still have to convert presenter DTOs into component props, so this alternative is rejected. + +### Have the Conversation Tool Definition produce one unified view + +This would put tool names and UI-card semantics into the target-neutral Conversation owner and recreate an intermediate DTO isomorphic to the Host view, so this alternative is rejected. + +### Delete `presentationMeta` + +Read line structure, applied diffs, search grouping, web sources, and effective truncation cannot be recovered losslessly from model text. Parsing free-form text would also bind the UI to output wording, so this alternative is rejected. + +### Persist canonical tool results + +This would enlarge the Session log, expose internal result structures, change the durable format, and potentially store objects far larger than presentation requires. Existing metadata is sufficient, so this alternative is rejected. + +### Delete Host presenter APIs + +Deleting them would shrink more code, but the decision preserves Host `presentCall` and `presentResult`. Their APIs, implementations, tests, and types remain independent of Session Remote. + +### Import Host tool implementations into the Client + +Tool packages include Node, filesystem, subprocess, or provider dependencies and cannot enter the browser bundle. The Client consumes only raw JSON and maintains narrow parsers inside its own renderers, so this alternative is rejected. + +### Query presentation from the Host per result + +An on-demand RPC would turn one page read into N network calls and would still require Host lookups, scope restoration, callId recovery, and error coordination, so this alternative is rejected. + +### Allow presentation enhancements + +The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected. + +### Accept temporary Generic degradation + +Stopping view delivery before completing Client cards would temporarily degrade terminal, diff, read, search, web, and Deliverables behavior. Client-equivalent derivation and Host removal must land in the same releasable change. + +## Consequences + +The decision removes presentation work, repeated scans, and duplicate view payloads from Session reads. Its cost is that the retained Host presenter and Client card derivation can evolve independently, so both sides require owner-specific tests and Web equivalence remains an explicit product constraint. + +### Client and Host logic drift + +Each tool may have one Host render intent and one Client card derivation. They serve different consumers and do not share a runtime path. Unrefreshed browser expected outputs pin visual equivalence for the first-party Web experience, while Host presenter tests constrain only the Host API. + +### Same-named providers lack stable identity + +A raw event records the tool name but not the specific ToolDefinition. The Client uses valid event fields to preserve differences between standard and persistent shells. Ambiguous custom or malformed input falls back to Generic; the wire has no extra hint for theoretical extensibility. + +### Metadata is unknown JSON + +Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives. + +### Preset-owned projection availability + +History does not compensate for projection units absent from the current composition. A preset-owned unit that must remain visible across a cold read requires the shared Session preparation/projection composition to make its definition available before restore; history must not regain a preset-mount or presenter setup branch. + +### Two targets must stay synchronized + +Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition. + +### Deliverables has a hidden dependency + +Deliverables is not a visual component, so its mutation parser must remain synchronized with supported first-party write tools. Dedicated tests pin file chips and Markdown links independently of card screenshots. + +### Fixtures can create false confidence + +Fixtures send raw events and metadata rather than handwritten views. Real-Host assembly coverage remains necessary because fixture-only snapshots cannot prove the transport path. + +### Incorrectly refreshing snapshots + +This change promises unchanged user-visible output. A snapshot difference must be fixed in Client derivation. Expected outputs must not be refreshed unless the owner separately approves a specific visual change. + +### Documentation drift + +The Agent Note, package READMEs, cookbook, root rules, and generated references must change together whenever the raw journal or Client presentation owner changes. Host API documentation remains separate. + +### Remote protocol narrowing + +The absence of optional `view` is a prerelease wire-type decision shared by all consumers. There is no compatibility shim, dual-writing, or version negotiation. + +## Relationship to Existing Decisions + +This note partially supersedes the implementation fact in [Client tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology. + +This note preserves [toolview dissolution](2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`. + +This note narrows the consumer scope of the [render-intent union](2026-07-02-tool-render-intent-union.md). The Host APIs and types remain, while the Session Remote and Web Client do not consume them. This note owns the transport split without rewriting that presenter decision. + +This note updates the entry contract from [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md): the journal transports only raw events plus an independent projection baseline, not transient tool views. + +This note follows [Conversation Node assembly](2026-08-09-client-conversation-node-assembly.md): the Tool Definition owns event pairing and the call tree, while concrete card models remain in `ui-tool`. + +This note preserves result metadata from the [canonical tool output contract](2026-07-20-canonical-tool-output-contract.md), because it is the lossless, replayable input to Client derivation. + +## Deferred + +- A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it. +- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior. +- A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer. +- Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views. +- If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window. diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md new file mode 100644 index 0000000000..5a87433377 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -0,0 +1,705 @@ +# Agent Note: Client 从原始 Session 工具事件派生展示 + +Status: implemented + +[English](2026-08-23-client-derived-tool-presentation.md) | 中文 + +## Problem + +Session 历史是持久 journal 接口,工具卡片属于 Client 展示。在 `page`/`follow` 中计算卡片 view 会让历史读取依赖 Tools registry、Agent preset、恢复后的 scope、presenter 执行和临时 UI 类型。 + +`tool/result` 不重复记录工具名称和参数。Host 端结果展示因此需要 call index 或按 `callId` 回扫;`maxMessages` 不直接限制事件数量,工具密集页面上的重复扫描可能接近二次方成本。 + +Host 投影还会重复结构化数据。read、diff、search 与 web 结果已在 `tool/result.data.meta` 中持久化有界事实;另一份 view 只增加 Remote payload 与 Client 解码成本,不增加持久语义。 + +Client 已经拥有完整的工具展示入口。`ui-chat` 将 `tool/call`、`tool/result` 与 Code Dispatch 事件组装成稳定的 `ToolCallBlock`;`ui-tool` 拥有递归调用树、按工具名称分发的 `tool.call.toolview` keyed slot、Generic fallback、卡片模型和 details output;业务 Client 插件可以为自己的工具名称注册 renderer。 + +Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的两套解释。keyed renderer 是 Web 扩展点,因此中间 Host view 不提供独立 Web 能力。 + +`ToolDefinition.presentCall`/`presentResult` 仍是保留的 Host API;ACP 采用 automation-only 协议,仓库也没有生产 TUI consumer。是否删除这些定义与 Session 读取是否独立于展示是两个决定。 + +所需结果是一条原始 Session journal 和一个 Client 展示 owner,且不发生可见退化或顺带增强。专用卡片、交互和 Code Dispatch 拓扑保持稳定,transport 不再携带临时 view。 + +## Decision + +Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 + +Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。 + +Client `ui-tool` 继续负责 card model 和具体 renderer。每个 card model 改为直接读取 `ToolCallBlock` 中的工具名称、原始参数、结果内容、错误、持久 metadata、Session cwd 与 Host home,并生成与现有页面相同的组件 props。 + +Client 不建立第二套 presenter registry。工具名称分发只使用现有 `tool.call.toolview` keyed slot;Client 中的纯 card-model helper 属于 renderer 实现,不成为 Cordis service、公开 registry 或 wire DTO。 + +Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCallView`、`ToolResultView` 及现有 presenter 实现全部保留。Session Controller 不调用它们,Client 不导入或消费它们;未来非 Client consumer 是否使用它们不属于本决定。 + +`ToolOutputDefinition.presentationMeta` 与持久 `tool/result.data.meta` 保留。它们携带模型可见结果文本无法无损表达、而现有专用卡片需要的执行结果事实。Client 直接校验并消费 `meta`,不要求 Host 在历史读取时再把它转换成 view。 + +### 目标与非目标 + +| 类别 | 决定 | +|---|---| +| 不存在 | `SessionEventEntry.view`、`SessionToolView`、`SessionToolCallView` | +| 不存在 | `history.ts` 的 `viewFor`、`backscanArgs`、`parseToolCall`、`jsonView` 与 presenter scope lookup | +| 不存在 | follow 中只服务 presentation 的 `openCalls` 与 fallback event scan | +| 不存在 | Client Session 的平行 `views` 数组、Conversation input 的 `view`、Tool block 的 `callView`/`resultView` | +| 派生 | terminal、diff、read、search、web card model 读取 raw block/meta | +| 派生 | Deliverables 读取成功 mutation 的名称与参数 | +| 保留 | Host `ToolDefinition.presentCall`/`presentResult` API、类型、实现与直接测试 | +| 保留 | `output.presentationMeta` 与持久 `tool/result.data.meta` | +| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | +| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | +| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | +| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 | +| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | + +## 术语 + +**原始 Session event**指持久日志中的 `SessionEvent` 事实,包括 `tool/call` 的 `name` 与原始 `arguments` 字符串,以及 `tool/result` 的 `content`、`isError`、结构化错误和可选 `meta`。 + +**持久 metadata**指 `ToolOutputDefinition.presentationMeta` 在工具成功执行时生成并写入 `tool/result.data.meta` 的 JSON 值。它是结果事实的一部分,不是预先排版的 React 或 card DTO。 + +**Host tool view**指 `ToolDefinition.presentCall`/`presentResult` 返回的 `ToolCallView`/`ToolResultView`;Session Remote 不运输它。 + +**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 + +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 + +**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 + +**无增强**指本决定不让被固定为 Generic fallback 的输入获得新专用卡片,也不扩大已有卡片的数据或交互。 + +## 架构与所有权 + +### 工具执行与持久化 + +1. 工具注册 `output.schema`、`output.render` 和可选 `output.presentationMeta`。 +2. 成功执行产生 canonical JSON value。 +3. Tools runtime 对 value 做快照、schema 校验和冻结。 +4. `output.render(args, value)` 生成模型可见 `ContentBlock[]`。 +5. 顶层调用若声明 `output.presentationMeta`,runtime 同时生成 JSON-safe metadata。 +6. Agent loop 把模型可见结果与 metadata 写入 `tool/result` Session event。 +7. Session log 不保存 `ToolCallView` 或 `ToolResultView`。 + +### Host journal 读取 + +1. `session.page` 取得 attached 或 persisted 事件。 +2. `paginate()` 按 append-origin user/assistant message 边界切页。 +3. tail page 通过已注册 projection 的 snapshot/restore 路径取得 baseline。 +4. 每个 page entry 只包含 `{event}`。 +5. `session.follow` 先建立 listener,再执行 catch-up read、发送 opening cursor 并流式下发连续 `{event}` frame。 +6. 两条路径都不为展示解析 preset/Tools scope、解析工具参数、调用 presenter 或建立 call index。 + +### Client 数据与展示 + +1. Client Session 保存一个连续 raw event window。 +2. `SessionEventSource` 发布只含 event 的 `SessionEventEntry`。 +3. `ui-conversation` 在没有 presentation companion 的情况下 fold 每个事件。 +4. Chat 与 Trajectory Tool Definition 按 callId 配对顶层 call/result,并组装 Code Dispatch 子树。 +5. `RunningToolCall` 与 `ToolResultNode` 保存 raw facts、metadata 与既有 parent identity。 +6. `ToolCallTree` 按 wire tool name 分发 `tool.call.toolview`。 +7. `ui-tool` 在 render site 从 block 派生 card component props。 + +### 生产消费者审计 + +| 对象 | 生产者 | 生产消费者 | 决定 | +|---|---|---|---| +| `presentCall`/`presentResult` | 各 Host 工具 | 可能存在的非 Client caller | 保留在 Session Remote 之外 | +| `SessionEventEntry.view` | 无 | 无 | wire 不存在 | +| `callView`/`resultView` | 无 | 无 | Client model 不存在 | +| `presentationMeta` | Tools runtime | `tool/result`、Client card model 与 Host presenter | 保留的持久输入 | +| fixture presenter mirror | 无 | 无 | fixture 下发 raw metadata | + +ACP 不消费 Session tool view,也不映射 Host render intent。仓库没有生产 TUI consumer;Host presenter 保留,但 Session Remote 不作为其 transport。 + +## 数据流 + +```text +Tool execute + -> canonical value + -> output.render(args, value) + -> model-visible result content + -> output.presentationMeta(args, value), when declared + -> durable tool/result event + +Session page/follow + -> raw Session event envelope + -> no tool lookup + -> no preset lookup for presentation + -> no call backscan + -> no render-intent serialization + +Client SessionEventSource + -> Conversation Tool Definition + -> root call/result pairing + Code Dispatch topology + -> ToolCallBlock(name, argsRaw, content, error, meta) + -> tool.call.toolview keyed dispatch + -> Client card model + -> existing React component +``` + +这条链路保留一次持久 metadata 投影,因为它发生在 canonical result 尚在内存时;删除的是读取历史时的第二次展示投影。 + +### 分层责任 + +| 层 | 负责 | 不负责 | +|---|---|---| +| Tools runtime | 执行、canonical value、模型文本、可重放 metadata | Web 卡片选择和组件 props | +| Session log | 持久事实、顺序、回放 | 临时 card DTO | +| Session Controller | 地址、权限、冷读、分页、follow、projection baseline | tool lookup、presenter、展示 scope | +| Client Session | Remote journal 生命周期与连续窗口 | 工具含义、卡片类型 | +| Conversation Tool Definition | call/result 配对、lifecycle、root/subcall topology | 工具名到组件的解释 | +| `ui-tool` | card model、通用 fallback、Chat/Details 展示 | Session 分页与 Host registry | +| 业务 Client 插件 | 自有 tool name 的 keyed renderer | root/subcall 编排与全局 registry | +| `ui-deliverables` | 当前第一方 mutation 的 produced path | UI card 或 Host render intent | + +## Remote 与持久数据约定 + +### `SessionEventEntry` + +`SessionEventEntry` 保留为 journal entry envelope,只含 `event: SessionWireEvent`。本次不顺带把 page entries 改成裸事件,也不重构 `RemoteJournalStream` 的通用 entry 约定。 + +`SessionPage.events` 仍是 `SessionEventEntry[]`。 + +`SessionFollowFrame` 仍是 opening frame 或带 `event` 的 event frame。 + +删除 `SessionToolCallView`、`SessionToolView` 和 `SessionEventEntry.view`。 + +Client connection 不再从 `dsh-tools/presentation` 转出 `ToolCallView`/`ToolResultView` 供 Session 消费。 + +生成 catalog 与 graph 从各自 source owner 派生已收窄的 Remote 类型和 package dependency。 + +### 持久日志 + +- `tool/call.data.name` 保持原样。 +- `tool/call.data.arguments` 保持模型产生的原始 JSON 字符串。 +- `tool/result.data.message.content` 保持模型可见结果。 +- `tool/result.data.error` 保持结构化失败身份。 +- `tool/result.data.meta` 保持工具私有 JSON 值。 +- Client card model 不写入 Session log。 +- renderer key 与 Host tool implementation id 不写入 Session log。 +- 现有持久 Session 无需迁移,`SESSION_FORMAT_VERSION` 不变。 + +### `presentationMeta` + +`presentationMeta` 不是 Host tool view。它在工具执行完成时读取 canonical value,而该 value 不会持久化;删除它会使下列现有展示无法无损恢复: + +- read 的 path、offset、lines、totalLines 与 lang; +- write/edit 的 applied contextual hunks; +- grep/glob 的分组结果、截断标志与总数; +- web_search 的来源字段与 provider answer; +- web_fetch 的最终 URL、HTTP status 与有效截断标志。 + +Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名为更中性的 result metadata 不属于本决定。 + +## Host 端设计 + +`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。 + +`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。 + +Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。 + +不可变 event 转换 helper 可以保持窄实现或内联;只要 history 不执行 presentation 工作,其名称没有语义。 + +Session Controller dependency 只在其他 package responsibility 需要时保留;manifest 与 project reference 不含 presentation-only dependency。 + +### 性能约束 + +- `page()` 的工具相关工作为零。 +- 页面增加 tool result 不增加对既有页面事件的重复扫描。 +- `follow()` 不维护展示索引。 +- history 不触发 Cordis `tools` service proxy。 +- history 不等待 presenter standing scope。 +- history 不执行工具参数 JSON parse。 +- history 不执行 tool view JSON clone。 +- Remote payload 不重复携带 `meta` 已表达的结构化数据。 +- Client 不扫描完整 Session event window 生成单个卡片。 +- Client 只在对应 immutable Tool block 变化时重新派生 card model。 + +## Client Session 与 Conversation + +Client Session 不含与 raw event window 平行的私有 `views` 数组。`installWindow()`、`prependWindow()` 和 `appendLive()` 只处理 event entries、cursor/hasMore、queue、projection 与通知。 + +`ConversationEventInput` 只携带 `event`。Conversation assembler 不认识 `SessionToolView`,其 replace/prepend/append、Context identity、Location 与 publication cadence 不变。 + +Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成以下数据: + +- callId; +- tool name; +- raw arguments; +- turn、step、seq 与 time; +- result content; +- isError 与 structured error; +- result metadata; +- root/subcall parent-child topology; +- interruption synthetic result。 + +`RunningToolCall` 不含 `callView`。 + +`ToolResultNode` 不含 `callView` 与 `resultView`。 + +`ToolCallBlock` 不新增通用 `view`、`card`、`kind` 或 `locations` 字段替代被删除字段。具体展示仍只属于 `ui-tool` 与 keyed renderer。 + +### Root 与 Code Dispatch 子调用 + +Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。 + +Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。 + +Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。 + +keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。 + +### 缺失调用头 + +结果节点在当前窗口没有配对 call 时,`ToolResultNode.call` 保持 `null`。Client 不扫描窗口、不发额外 RPC,也不根据 result 文本猜测工具名称。 + +需要名称或参数的专用派生在 `call === null` 时走当前 Generic fallback。只依赖 result metadata 的模型也不借机增强,因为当前 Host `presentResult` 必须先取得配对调用。 + +older page 后续补入调用头时,Conversation Context 按既有 replay 规则重建,届时才允许生成当前已有的专用卡片。 + +### 参数与 metadata 收窄 + +Client 从 `argsRaw` 解析 JSON,解析失败返回 Generic,不抛出 React render 错误。 + +Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必须使用 immutable block identity,不能按 callId 建立跨 Session 全局状态。 + +每个专用模型只检查它需要的字段。Client 不复制完整 Host tool schema,也不调用 Host `defineTool` validator。 + +合法第一方事件必须与当前 presenter 输出等价。畸形、旧版本或手工修改日志只承诺不崩溃并使用 Generic fallback。 + +## Client card-model 设计 + +现有 `ui-tool/src/client/tool/models/` 继续是 Chat 与 Details 共享派生的唯一位置。helper 直接返回组件 props,不返回 `ToolCallView`/`ToolResultView`,也不创建同构的 `ClientToolView` union。 + +工具名称分支只存在于 `ui-tool` card model、现有 row 分类表,或拥有该工具 keyed renderer 的 Client 插件;不得进入 Session Controller、Client Session、Conversation assembler 或通用 Slot renderer。 + +未知工具继续由 `GenericToolCard` 显示 name、原始 args、结果 content 与错误。 + +### 通用工具行 + +`toolRowModel()` 直接从 `toolName`、`argsRaw`、result content、error、cwd 与 home 派生通用行,并保持以下行为: + +- `search`、`read`、`bash`、`write`、`edit`、`code` 与 `others` 分类; +- 现有标题与工具专用标题; +- summary 字段优先级和单行截断; +- 多 query 的逗号拼接; +- cwd 相对化与 home 缩写; +- file path 点击; +- args pretty JSON 与非 JSON 原文 fallback; +- result content flatten 与 structured error fallback; +- running、ok、error 与 stopped 状态。 + +Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。 + +### Terminal 卡片 + +Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。 + +| 输入 | 保持的结果 | +|---|---| +| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | +| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| `run_in_background:true` | Generic 行与原始结果 | +| 工具执行 error | Generic IN/OUT 与错误摘要 | +| persistent `bash`/`pwsh` running | terminal prompt | +| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card | +| `terminal_send` 前台 | terminal prompt 与 output | +| `terminal_send` background/error | Generic 结果 | +| Code Dispatch child | 当前 flattened Generic 形态 | + +标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。 + +调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。 + +对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。 + +同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。 + +TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。 + +### Diff 卡片 + +| 输入 | 保持的结果 | +|---|---| +| running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff | +| running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff | +| running `str_replace_editor create` | 从 `path` 与 `file_text` 生成 intended added-only diff | +| running `str_replace_editor str_replace` | 从 `path`、`old_str` 与 `new_str` 生成 intended replacement diff | +| settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks | +| settled `str_replace_editor` | Generic,因为该工具没有 result presenter | +| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback | +| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic | + +路径、`oldText:null`、`newText`、结果覆盖调用时 diff、Chat 8 行上限、Details 全高显示和文件打开行为不变。 + +### Read 卡片 + +running `read` 继续只有摘要行。成功 settled `read` 从 result meta 读取 path、offset、lines、totalLines 与 lang,并确认结果是单个文本块且符合 read envelope。 + +meta 缺失、字段畸形、result envelope 不匹配、error、缺失 call head 或 Code Dispatch child 都走 Generic。路径 label 的 cwd 相对化、home 缩写、语法语言、总行数、Chat 8 行上限与 Details 全高显示不变。 + +Client 不需要构造 Host `ReadResultView.content`;Generic fallback 始终可直接读取原始 result content。 + +### Search 卡片 + +running `grep`/`glob` 继续只有参数摘要。成功结果分别从 `meta.shape:'matches'` 与 `meta.shape:'paths'` 生成 grouped matches 或 path list。 + +Client 校验 path、lineNumber、line、truncated 与 total。空 matches/paths 是有效卡片;缺失/畸形 meta、未知 shape、error、缺失 call head 与 Code Dispatch child 走 Generic。 + +`truncated:true` 时继续从原始 result content 显示 recovery locator;未截断时不显示。Chat 8 行上限、Details 全高显示和展开行为不变。 + +### Web 卡片 + +running `web_search`/`web_fetch` 继续只有摘要行。成功 search 从 `meta.sources`、`meta.answer`、`meta.truncated` 生成卡片;成功 fetch 从 `meta.url`、`meta.statusCode`、`meta.truncated` 生成卡片。 + +Client 校验每个 source 的 url、title、snippet 与 publishedAt,并继续只把 http/https URL 渲染为链接。meta 缺失或畸形、error、缺失 call head 与 Code Dispatch child 走 Generic。 + +search 的 answer、来源顺序、label fallback 与截断提示不变;fetch 的最终 URL、状态、截断提示与 Details 下方原始正文不变。 + +### 已直接使用 raw block 的 renderer + +- Todo row 继续从 args 计算 completed/active 摘要。 +- Question row 继续从 result content 与 error 计算等待、回答、取消和中止状态。 +- Skill row 继续从 args/result 计算名称与状态。 +- Cordis define/run/action rows 继续从 args/result 与各自 Client service 计算。 +- 这些 renderer 的 props、slot key、注册顺序与可见结果不变。 + +## Deliverables + +`ui-deliverables` 独立于展示意图派生 mutation 业务事实,因此 produced-file 行为不与卡片截图耦合。 + +Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/result`,保存最小的 Client-owned mutation candidate,不扫描 Session window,也不依赖 UI renderer。 + +| 工具 | mutation 判定 | path 来源 | +|---|---|---| +| `write` | 任意成功调用 | `file_path` | +| `edit` | 任意成功调用 | `file_path` | +| `str_replace_editor` | `create`、`str_replace`、`insert` | `path` | +| `str_replace_editor` | `view` | 不产生 path | +| 其他 | 无当前第一方 mutation 语义 | 不产生 path | + +失败、interrupted、orphan result、缺失 path 与畸形 args 不产生 deliverable。同一路径保持 first-seen 去重,closing Assistant seq 之后落定的结果继续排除。 + +本次不新增通用“工具副作用”注册表。Host-only 第三方 presenter 通过 `kind:'edit'`/`locations` 自动加入 Deliverables 的能力被有意移除;未来若有真实第三方 mutation 需求,应由 Client 业务贡献表达,不能恢复 Session view。 + +## Fixture 与测试数据 + +Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。 + +| Fixture | 必须保留的原始事实 | +|---|---| +| terminal | 参数与真实结果 status marker | +| diff | 参数与 result `meta.diffs` | +| read | result meta 的 path/offset/lines/totalLines/lang | +| grep/glob | result meta 的 shape/files 或 paths/truncated/total | +| web | result meta 的 sources/answer 或 url/statusCode/truncated | +| generic/custom | name、argsRaw、content、error | + +fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。 + +## 展示等价矩阵 + +“当前展示”由已提交的组件测试、组装测试与 Web browser expected 共同定义。transport 或 ownership 重构不能作为 refresh snapshot 的理由;获批产品变化需要独立证据。 + +| 场景 | 必须保持的展示 | +|---|---| +| 未知工具 running | Generic 行,工具名与 args 摘要 | +| 未知工具 settled | Generic 行与原始 output | +| malformed args | 安全 Generic fallback | +| orphan result | callId 标题与 Generic output | +| interrupted call | warning/stopped 状态 | +| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 | +| bash/pwsh background/error | 当前 Generic IN/OUT | +| persistent shell | 当前 running terminal、settled Generic | +| terminal_send | 当前前台 terminal、后台/error Generic | +| write/edit | 当前 intended/applied diff 与 error fallback | +| read | 当前 running 摘要、settled ReadBlock 与 error fallback | +| grep/glob | 当前 grouped/path card、截断与 recovery | +| web_search/web_fetch | 当前来源/摘要 card 与原始正文 | +| Todo/Question/Skill/Cordis | 当前专用行 | +| Code Dispatch subcall | 当前 Generic/flattened 形态 | +| Chat 与 Details | 同一调用使用相同 card fields | +| Trajectory | 当前 identity、树、选择和 details | +| Deliverables | 当前成功 mutation chips 与链接 | + +## Client 扩展约定 + +`tool.call.toolview` 继续是唯一工具 UI 注册机制。一个工具若要在 Client 获得专用表现,必须由 Client 插件注册自己的 wire tool name。 + +注册方接收 raw `ToolCallBlock`、Session path 信息和宿主动作,自行校验它认识的 args/meta 字段。注册方不调用 Host tool registry,不依赖 `presentCall`/`presentResult`,也不能要求 `SessionEventEntry.view`。 + +没有 Client renderer 的工具稳定降级为 Generic。同一 tool name 只能有一个生效 keyed registration,重复 key 继续 loud failure。 + +Session-scoped slot 可以表达 Client 侧会话差异,但不从 preset 推断 renderer 变体。Host-only presenter 不自动赋予 Web rich card,这是“Host 描述展示”与“Client 插件拥有展示”的明确边界。 + +## 失败与 fallback + +- Client 把 args 与 meta 当作 wire JSON,在消费点收窄。 +- 参数 JSON 解析失败走 Generic。 +- 已知工具缺少必要字段走 Generic。 +- metadata 缺失或畸形走 Generic;成功 `write` 例外,它按当前 presenter 行为保留由参数派生的整文件 diff。 +- error result 不因 metadata 存在而显示成功卡片。 +- 缺失 call head 不猜测工具名称或参数。 +- 未知 metadata 字段被忽略。 +- 新 metadata variant 在旧 Client 中走 Generic。 +- card-model helper 捕获可预期解析失败,不依赖 React error boundary 完成普通 fallback。 +- keyed renderer 自身的意外异常仍由现有 Slot error isolation 处理。 + +## 同名 Host provider + +Host registry 允许不同 scope 为同一 tool name 提供不同定义;Session view 通过 presenter scope 理论上可以按 preset 选择不同 render intent。删除 view 后,Client keyed slot 只观察 wire name,不能观察 Host definition identity。 + +当前第一方显著实例是普通与 persistent `bash`/`pwsh`。Client 派生使用有效参数与结果特征保持它们的已交付差异,不增加 provider-id wire 字段;无法判别的畸形或自定义同名 provider 输入采用 Generic。 + +本次不承诺保留第三方同名 provider 仅通过 Host presenter 表达的差异。若未来产品确需同名 provider 的不同 Client 展示,必须定义稳定、非展示性的 Client identity;不得恢复按页 Host view 计算。 + +## 已交付范围 + +### Session Controller + +- `SessionEventEntry` 只包含 raw event。 +- 两个 Session tool-view 类型都不存在。 +- history 不含 presentation import、helper 或 page/follow presentation state。 +- 地址、分页、follow 与 projection 逻辑仍由 Session owner 负责。 +- Host 测试固定 raw journal 约定。 + +### Session Controller Client + +- `Session.views` 不存在。 +- EventSource replace/prepend/append delta 保持不变。 +- transport、fixture 与 test-support 类型携带 raw entry。 +- event identity 与引用稳定性保持不变。 + +### UI Conversation、Chat 与 Trajectory + +- Conversation input 与 Tool block 不含 view 字段。 +- Chat/Trajectory Tool Definition 读取 raw event。 +- event pairing、Context replay、树与 target snapshot 保持不变。 +- child Tool block 保留现有 Code Dispatch `parentCallId`;row 与 Details slot owner props 都不增加独立 placement 字段。 + +### UI Tool 与 Deliverables + +- card model 从 raw block/meta 派生。 +- Chat 与 Details 复用相同 helper。 +- Generic fallback 与 keyed dispatch 保持不变。 +- Deliverables 识别第一方 mutation args。 + +### Fixture、文档与生成物 + +- fixture 只发 raw event/meta。 +- Session Controller 与 Client README/JSDoc 描述 raw journal 和 Client presentation owner。 +- 工具 cookbook 记录 Web Client 接入路径。 +- 本文是该决定的 owner;保留的 Host presenter Note 继续拥有各自决定。 +- 手写 Remote 类型、dependency、README、pairing record 与 generated reference 保持同步。 + +## 验证矩阵 + +### Host + +- page 返回连续 raw event entries。 +- follow 返回 opening cursor 与连续 raw event entries。 +- page/follow 在无 Tools service 时行为相同。 +- cold page 不解析或挂载 preset。 +- tail page 通过标准 projection registry 计算 baseline;provider 是否存在由 projection composition 决定,不引入 history 侧 setup 路径。 +- 地址、ownership、message-aligned boundary 与 tail projection 不变。 +- listener-before-read、reconnect catch-up 与 gap repair 不变。 +- 大量 tool results 不触发每结果回扫。 +- wire 结果不含 view。 + +`session-history-journal.host.spec.ts` 负责分页、连续性和 history error 行为,不含 presenter 断言。 + +### Client Conversation + +- replace、prepend 与 append 接受无 view entry。 +- Chat 与 Trajectory root call/result 配对不变。 +- Code Dispatch 树不变。 +- result-only fallback 不变。 +- interruption synthetic result 不复制 view。 +- registry rebuild、older prepend 与 live append 的 Node identity 不变。 + +### Client card model + +- terminal 用 raw args/content 得到已固定的 props。 +- diff 用 args/meta 得到已固定的 diffs。 +- read 用 meta/content 得到已固定的 lines。 +- search 用 meta/content 得到已固定的 grouped/path card 与 recovery。 +- web 用 meta/content 得到已固定的 sources/fetch summary。 +- unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。 +- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。 +- Chat 与 Details 对同一 block 得到相同 card fields。 + +### Deliverables + +- write/edit 成功产生 `file_path`。 +- str_replace_editor create/str_replace/insert 产生 `path`。 +- str_replace_editor view 不产生 path。 +- failure、interrupted、malformed 与 orphan 不产生 path。 +- first-seen 去重与 closing seq cut 不变。 + +### 组装与浏览器 + +- terminal、diff、read、search、web browser expected 不刷新并全部通过。 +- tool tree、details、trajectory 与 deliverables 的可见断言不改预期。 +- built Client 通过真实 Remote page/follow 取得 raw events 后仍显示同样卡片。 +- fixture 与真实 Host 使用同一 Client derivation。 +- minimal preset 单独固定 persistent shell 行为。 + +### 静态与文档 + +- 生产代码不存在 `SessionToolView`/`SessionToolCallView`。 +- Session history 不引用 `dsh-tools/presentation`、`ctx.tools`、`presenterScopeFor` 或 `backscanArgs`。 +- Client Conversation 不引用 `ToolCallView`/`ToolResultView`。 +- Client model 不读取 `callView`/`resultView`。 +- fixture 不定义 presenter mirror。 +- Host `presentCall`/`presentResult` 与 `presentationMeta` 仍存在。 +- 没有新增 Client registry 或 Host→Client presentation hint。 +- 受影响的手写类型、README、Agent Note、catalog 与 graph 保持同步。 + +## 验证命令 + +修改本决定时使用 `dsh-pre-push-checks` 按最终 diff 选择命令;所需证据包括: + +- Session Controller history/transport 聚焦测试; +- ui-chat 与 ui-trajectory Tool Definition 测试; +- ui-tool terminal、diff、read、search、web、row、tree 与 details 测试; +- ui-deliverables produced-files 测试; +- connection fixture 与 Client runtime 测试; +- 受影响 Host/Client TypeScript face; +- lint 与 duplication; +- 受影响源文件 per-file 100% coverage; +- `DSH_SNAPSHOT=replay pnpm run test:web`,不得 refresh 现有展示 golden; +- 手写 Remote 类型与 TypeScript 检查; +- `pnpm run doc-sync`; +- `git diff --check`。 + +## 已交付不变量 + +- Session page/follow 不读取 Tools registry 或 presenter scope。 +- Session history 不存在 callId backscan、presentation cache 或 view clone。 +- Remote Session entry 不携带 view。 +- Session 日志与 `SESSION_FORMAT_VERSION` 不变。 +- result meta 逐字节通过日志与 Remote 到达 Client。 +- Conversation 只从 raw event 组装 ToolCallBlock。 +- ToolCallBlock 不含 Host render-intent 字段。 +- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。 +- Generic、Todo、Question、Skill 与 Cordis 行行为不变。 +- Deliverables 不依赖 render intent 且保持当前 paths。 +- 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。 +- malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。 +- Code Dispatch 子调用保持 Generic/flattened。 +- Chat、Details 与 Trajectory 行为不变。 +- 现有 Web browser expected 无需刷新即可通过。 +- Host presenter API、实现与直接测试不变。 +- ACP 输出不变。 +- 没有新下行展示字段或第二套 Client registry。 +- 分页成本不再随 result 数量乘以页面事件数增长。 +- 下行 payload 不再重复 result meta 的 card DTO。 + +## Alternatives considered + +### 只优化 `backscanArgs`,保留 view + +page 前建立一次 `callId → {name,args}` Map 可以把回扫降为线性,live 已有 `openCalls` 快路径;但 Host lookup、preset scope、presenter、JSON clone、重复 payload 和双重所有权仍存在,因此拒绝。 + +### 在 Client 建 presenter registry + +把 `presentCall`/`presentResult` 接口复制到浏览器会与 `tool.call.toolview` slot 重复注册、生命周期、fallback 和覆盖语义;renderer 仍需把 presenter DTO 转成组件 props,因此拒绝。 + +### 让 Conversation Tool Definition 生成统一 view + +这会把工具名称和 UI card 语义放进 target-neutral Conversation owner,并重建与 Host view 同构的中间 DTO,因此拒绝。 + +### 删除 `presentationMeta` + +read 行结构、applied diff、search 分组、web sources 和有效 truncation 无法从模型文本无损恢复;解析自由文本也会把 UI 绑到输出措辞,因此拒绝。 + +### 持久化 canonical tool result + +这会扩大 Session log、暴露内部结果结构、改变持久格式,并可能保存远超展示所需的大对象;已有 metadata 足够,因此拒绝。 + +### 删除 Host presenter API + +一并删除可以继续收缩代码,但本决定保留 Host `presentCall`/`presentResult`;其 API、实现、测试与类型独立于 Session Remote。 + +### Client 导入 Host 工具实现 + +工具包包含 Node、filesystem、subprocess 或 provider 依赖,不能进入浏览器 bundle;Client 只消费 raw JSON,并在自己的 renderer 内维护窄解析,因此拒绝。 + +### 按结果向 Host 查询 presentation + +按需 RPC 会把一页读取变成 N 次网络调用,仍需 Host lookup、scope、callId 查找与错误协调,因此拒绝。 + +### 允许展示增强 + +Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。 + +### 接受临时 Generic 退化 + +先停发 view 再逐步补 Client card 会让 terminal、diff、read、search、web 与 Deliverables 在中间版本退化。Client 对等实现与 Host 删除必须在同一可发布变更中完成。 + +## Consequences + +本决定从 Session 读取中删除 presentation 工作、重复扫描和重复 view payload;代价是保留的 Host presenter 与 Client card derivation 可以独立演进,因此两侧都需要 owner 专属测试,Web 展示对等仍是明确产品约束。 + +### Client 与 Host 逻辑漂移 + +同一工具可以有一份 Host render intent 和一份 Client card derivation。两者面向不同消费方,不共享运行路径;不刷新的 browser expected 固定第一方 Web 视觉对等,Host presenter 测试只约束 Host API。 + +### 同名 provider 无稳定 identity + +raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用有效事件字段保留普通与 persistent shell 的差异;无法判别的自定义或畸形输入回退 Generic,wire 不为理论扩展性增加 hint。 + +### Metadata 是未知 JSON + +旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。 + +### preset-owned projection 可用性 + +history 不为当前组合中缺失的 projection unit 补偿。需要在冷读中保持可见的 preset-owned unit,必须由共享的 Session preparation/projection 组合在 restore 前提供其定义;history 不得重新增加 preset mount 或 presenter setup 分支。 + +### 双 target 同步 + +Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。 + +### Deliverables 隐性依赖 + +Deliverables 不是视觉组件,因此 mutation parser 必须与受支持的第一方写工具保持同步;专用测试独立于卡片截图固定 file chips 与 Markdown links。 + +### Fixture 假绿 + +fixture 下发 raw event/meta,不下发手写 view。真实 Host 组装覆盖仍然必要,因为 fixture-only snapshot 不能证明 transport 路径。 + +### 错误刷新快照 + +本次承诺用户可见输出不变。出现 snapshot diff 时必须修 Client 派生;除非 owner 单独批准具体视觉变化,否则不得 refresh expected。 + +### 文档漂移 + +raw journal 或 Client presentation owner 变化时,Agent Note、package README、cookbook、根规则与 generated reference 必须一起更新;Host API 文档保持独立。 + +### Remote 协议收缩 + +optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型决定;没有兼容 shim、双写或版本协商。 + +## 与现有决策的关系 + +本文部分取代 [Client 工具展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。 + +本文保留 [toolview 溶解](2026-07-23-toolview-dissolution.zh.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。 + +本文收窄 [render-intent union](2026-07-02-tool-render-intent-union.zh.md) 的消费范围:Host API 与类型保留,Session Remote 与 Web Client 不消费它。本文独自规定 transport 拆分,不改写该 presenter 决策。 + +本文更新 [Session 历史与 Remote 事件传输](2026-08-18-session-history-and-event-transport.zh.md) 的 entry 约定:journal 只运输原始 event 与独立 projection baseline,不承载临时 tool view。 + +本文遵循 [Conversation Node 组装](2026-08-09-client-conversation-node-assembly.zh.md):Tool Definition 负责事件配对与调用树,具体 card model 留在 `ui-tool`。 + +本文保留 [规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md) 的 result metadata,因为它是无损、可重放 Client 派生的输入。 + +## Deferred + +- Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。 +- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。 +- 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。 +- 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。 +- Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml index d7d1c8e67b..8cf0ccbbd2 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md -2026-08-06-bundled-dsh-badge-skill.md: 4c6fbdcb76298759c2f17b3b5349a5b48198595e -2026-08-06-bundled-dsh-badge-skill.zh.md: fe291d498660e744cf193e123aa1a4a4f4a27b3b +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b +2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md new file mode 100644 index 0000000000..7fa2d60f14 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -0,0 +1,42 @@ +# Agent Note: Locale-owned client UI copy + +Status: implemented + +English | [中文](2026-08-23-locale-owned-client-ui-copy.zh.md) + +## Problem + +Typed locale namespaces and bilingual dictionary parity proved that registered dictionaries were complete, but they could not prove that presentation code used them. JSX text, accessibility attributes, formatter returns, and zero-Cordis primitive defaults could bypass `t` while every locale check remained green. The deferred and supposedly language-neutral exceptions recorded in the [initial full-rollout decision](2026-07-30-client-locale-full-rollout.md) accumulated into a mixed-language UI, especially in trajectory inspection and generic Tool cards. + +## Decision + +**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. + +**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. + +**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state. + +**`verify-client-ui-i18n` enforces source ownership.** The TypeScript-AST check discovers every package `src/client` tree that contains TSX, all helper TS files under `packages/client/ui-*`, and the web app source. It rejects natural-language JSX text, copy-bearing attributes and component props, literal JSX branches, label/copy data, named copy helpers, string-returning display formatters, and destructuring defaults. Locale dictionary owners and immutable language tokens are the narrow syntactic exclusions. Discovery refuses a narrowed corpus, unit fixtures pin admitted and excluded forms, and the check runs in the static CI and `hygiene` graphs. Dictionary-key parity remains a separate check: one gate proves copy enters the locale path, while the other proves both shipped languages implement that path. + +The product-authored error and design-literal exclusions, primitive defaults, and trajectory deferral in the [initial rollout](2026-07-30-client-locale-full-rollout.md) are superseded by this decision. Its label-thunk, typed-seat, browser-locale, date-formatting, and search-placeholder decisions remain active. + +## Verification + +The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. + +## Alternatives considered + +**Rely on review and AGENTS.md alone.** Rejected because the existing rule and typed dictionaries coexisted with hundreds of bypasses; reviewers need a source-level failure at the introducing line. + +**Use a text regex or ban every string literal.** Rejected because TypeScript and JSX contain imports, CSS classes, discriminants, event names, SVG data, and user/wire values. Syntax-aware contexts provide useful signal without an ever-growing file allowlist, while the minimum discovery count prevents a falsely green narrowed scan. + +**Keep primitive fallback copy for convenient direct use.** Rejected because a fallback is itself an implicit locale choice. Required label props keep primitives framework-free and make each product render site name its copy owner. + +**Translate every string that reaches the DOM.** Rejected because authored data and protocol/code tokens are not product wording. Translating them corrupts evidence, identifiers, commands, paths, URLs, and provider diagnostics; only surrounding product chrome belongs to the locale system. + +## Consequences + +- Adding or changing client UI copy requires a typed dictionary key in both locales and behavior evidence for the affected render path. +- Pure primitives have larger explicit prop types, and tests provide deliberate label fixtures; this cost prevents hidden locale behavior. +- The AST check catches authored literal bypasses but cannot prove that an arbitrary dynamic string prop was translated. Types, dictionary parity, component tests, and review still own that semantic distinction. +- Boot markup that renders before the locale service and externally authored runtime data remain outside the dictionary path; product UI replaces boot copy after locale activation. diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md new file mode 100644 index 0000000000..5515699bb1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -0,0 +1,42 @@ +# Agent Note: locale 归属的 client UI 文案 + +Status: implemented + +[English](2026-08-23-locale-owned-client-ui-copy.md) | 中文 + +## Problem + +typed locale namespace 与双语字典对等性可以证明已注册字典完整,却无法证明展示代码使用了字典。JSX 文本、无障碍属性、格式化函数返回值和 zero-Cordis 原子组件默认值都可能绕过 `t`,而全部 locale 检查仍保持绿色。[最初的全量接入决策](2026-07-30-client-locale-full-rollout.zh.md)中缓做或假定为语言无关的例外逐渐形成混合语言 UI,trajectory 检查面和通用工具卡尤为明显。 + +## Decision + +**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 + +**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 + +**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。 + +**`verify-client-ui-i18n` 强制源码归属。** 基于 TypeScript AST 的检查会发现每个包含 TSX 的 package `src/client` 目录树、`packages/client/ui-*` 下的所有辅助 TS 文件和 web 应用源码;它拒绝自然语言 JSX 文本、承载文案的属性与组件 prop、JSX 字面量分支、label/copy 数据、具名文案辅助函数、返回字符串的展示格式化函数和解构默认值。locale 字典 owner 与不可变语言 token 是严格的语法级排除项。发现范围缩窄会直接失败,单元 fixture 固定纳入与排除形态,检查加入静态 CI 与 `hygiene` 图。字典 key 对等性仍由独立检查负责:一道门禁证明文案进入 locale 路径,另一道门禁证明两种发布语言都实现该路径。 + +[最初接入决策](2026-07-30-client-locale-full-rollout.zh.md)中的产品自产错误与设计字面量例外、原子组件默认文案和 trajectory 缓做均由本决定取代;其 label thunk、typed 席位、浏览器 locale、日期格式化和搜索占位行决定仍有效。 + +## Verification + +AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 + +## Alternatives considered + +**只依赖评审与 AGENTS.md。** 否决。既有规则和 typed 字典与数百个绕过点同时存在;评审者需要在引入行收到源码级失败。 + +**使用文本正则,或禁止所有字符串字面量。** 否决。TypeScript 与 JSX 中包含 import、CSS class、判别值、事件名、SVG 数据和用户/wire 值。按语法上下文检查可在不扩张文件 allowlist 的情况下保持有效信号,而最小发现数量可防止扫描范围缩小后伪绿。 + +**为方便直接使用而保留原子组件回落文案。** 否决。回落值本身就是隐式 locale 选择。必填 label prop 让原子组件保持框架无关,并迫使每个产品渲染点明确文案 owner。 + +**翻译所有进入 DOM 的字符串。** 否决。外部编写的数据和协议/代码 token 并非产品措辞。翻译会破坏证据、标识符、命令、路径、URL 和提供方诊断;只有其周围的产品 chrome 属于 locale 系统。 + +## Consequences + +- 新增或修改 client UI 文案时,必须在两种 locale 中添加 typed 字典 key,并为受影响渲染路径提供行为证据。 +- 纯原子组件的显式 prop 类型变大,测试需提供有意选择的 label fixture;这项成本换来无隐藏 locale 行为。 +- AST 检查可以抓到产品编写的字面量绕过,却无法证明任意动态字符串 prop 已翻译。类型、字典对等性、组件测试和评审仍共同负责这一语义区分。 +- locale 服务之前渲染的 boot 标记和外部编写的运行时数据仍在字典路径之外;locale 激活后,产品 UI 会替换 boot 文案。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml new file mode 100644 index 0000000000..4260ae5238 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.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/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md +2026-08-23-webworker-vfs-watch-and-landlock.md: 2705c63aa6bf0f2e2de33d00029a4f41e1b1d4af +2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 4470720e1ff968aa06578b231b054c9053f27283 diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md new file mode 100644 index 0000000000..2705c63aa6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS watching and CLI-compatible confinement + +Status: implemented + +English | [中文](2026-08-23-webworker-vfs-watch-and-landlock.zh.md) + +## Problem + +The Web Worker preview boots the same Web profile and Agent presets as the Node host. Without a VFS change source, refusing `node:fs.watchFile` makes `skill-filesystem` return an incomplete observation and re-scan on every lookup, while an inert success leaves an existing root waiting forever for Chokidar's `ready`. Settings and credentials likewise need real external-edit events rather than a package-specific fake. + +The same composition mounts `sandbox-local`, whose Linux chain probes bwrap and then `@deepseek-ai/node-addon-landlock-run`. A Worker cannot execute either binary. Ending the selection there makes `workspace-write` and `read-only` unusable even though every shell filesystem operation already crosses a Host-side VFS call point. + +The filesystem compatibility boundary follows the [Worker Node face decision](2026-08-20-webworker-node-face.md): pure JavaScript watcher packages run unchanged over Node-compatible modules. Native or binary packages may keep their public JavaScript API and executable protocol while replacing the backend. An API that cannot preserve its caller-visible Node behavior remains explicitly unavailable; `node:vm` is outside this decision. + +## Decision + +### VFS mutation source and filesystem watchers + +`MemoryVfs` publishes committed `write`, `mkdir`, `remove`, and `chmod` mutations to any number of subscribers. Publication happens after state changes, failed operations publish nothing, image seeding stays silent, and one throwing subscriber cannot fail the filesystem operation or starve another subscriber. Rename is a source removal plus complete destination mkdir/write records; destination writes mark the directory entry as changed, so watchers report `rename` while a future durable sink receives the bytes needed to materialize the destination. Directory mtimes advance when their immediate entry set changes, so polling detects child creation and removal as Node does. + +The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`. + +The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. A pre-aborted callback watch returns its watcher before asynchronously closing it, while a pre-aborted promise watch rejects its first iterator read with `AbortError`. + +`fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events. + +### Streams and unchanged npm packages + +`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. Descriptors retain their opened file identity and access mode across rename, replacement, and unlink; hard links share that identity and subsequent content or mode changes, while truncation zero-fills growth. + +Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior. + +### Landlock CLI over per-process VFS grants + +`@deepseek-ai/node-addon-landlock-run` is an ordinary image dependency, not a module replacement. Its unchanged JavaScript entry runs through the Worker implementations of `node:child_process`, `node:module`, `node:path`, and `node:url`, so the package remains the sole owner of `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT`, `launcherPath()`, `grantArgs()`, and `probe()`. The image may include the matching Linux optional package, but package resolution does not decide whether the Worker platform supplies Landlock: the entry package's deterministic fallback path reaches the same platform executable implementation when that optional package is absent. + +The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend. + +Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Grant roots normalize trailing separators before containment checks. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes. + +The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`. + +### Explicitly deferred behavior + +`node:vm`, `node:worker_threads`, `node:net`, `node:sqlite`, native PTY, Sharp, and ripgrep remain outside this change. The VFS remains POSIX-only, in-memory, and symlink-free. Browser Workers have no libuv-style ref-counted event loop, so watcher `persistent`, `ref()`, and `unref()` preserve the API and observable state but cannot decide Worker lifetime. + +## Alternatives considered + +**Disable watcher and sandbox rows in the Worker profile.** A smaller composition would stop testing the same Host tree and would hide package integration failures specific to preview deployment. + +**Make `watchFile` an inert success.** Missing roots would never advance, and an existing root would wait forever for Chokidar `ready`. + +**Notify watchers only from `node:fs`.** Shell process requests and any direct VFS writer would bypass the notification point. The commit owner, `MemoryVfs`, is the only complete source. + +**Keep a VFS-specific Chokidar replacement.** This duplicates directory scans, ready accounting, write settling, atomic replacement, shared watcher ownership, and teardown already maintained upstream. + +**Replace the Landlock entry package with a Worker module.** Reimplementing its exported constants, grant builder, launcher resolution, and probe would create a second copy of a package contract that already runs over the Worker Node compatibility layer. Only the platform executable implementation differs. + +**Recognize one exact launcher path.** Optional-dependency installation and the entry package's documented fallback produce different absolute paths for the same executable. Package-manager layout is not the identity of a platform capability, so executable dispatch uses the logical `landlock-run` name. + +**Add a Worker branch to `sandbox-local`.** This would copy policy-to-grant mapping into a business package. Interpreting the existing launcher protocol preserves the provider, consumer, configuration, diagnostics, and native package API. + +**Store one active policy on the global VFS.** Concurrent foreground, background, and escalated commands would overwrite one another's authority. Grants belong to one process handle and its filesystem adapter. + +## Verification + +- `fs-watch-stream.spec.ts` compares missing/create/change/remove `watchFile` transitions and file-stream lifecycle, chunking, range, backpressure, byte count, defaults, and abort identity with the running Node version. +- `chokidar.spec.ts` loads both lockfile-selected Chokidar and readdirp dependency pairs through the Worker transformer and module loader, then proves `ready`, callback watching, polling, missing-file creation, removal, and quiescent close over `MemoryVfs`. +- `image-loadable.spec.ts` packs and loads the real `@deepseek-ai/node-addon-landlock-run` JavaScript, proves it is absent from the replacement table, and runs its fallback `launcherPath()` and `probe()` through the Worker platform executable. `child-process.spec.ts` and `sandbox-stack.spec.ts` then prove the launcher failure code, malformed argv and grant failures, `/tmp` and `/dev/null`, rename denial, all three permission modes, and concurrent process-local grants through the production sandbox and subprocess packages. +- `preview-boot.e2e.ts` builds and boots the packed browser deployment, creates a Workspace and Session, advances missing skill roots into a live Chokidar watch, lists the catalog, and completes settings and credential writes without watcher warnings. + +## Consequences + +The preview now runs npm watcher consumers without source forks, and filesystem mutations observed from Host code or shell process Workers share one ordered commit source. A WebFS/OPFS integration remains an asynchronous mirror around this synchronous authority and consumes that same source; it does not add another Chokidar implementation or a competing mutation protocol. + +Worker `read-only` and `workspace-write` preserve the product's permission vocabulary and denial reporting without forking the Landlock npm package. Their security claim is narrower than native Landlock but complete inside the Worker execution world; any new filesystem message or shell program must continue through the guarded `ShellFileSystem`. Native-backed packages follow the same ownership rule: their JavaScript remains upstream, while the Worker platform replaces only the native artifact behind it. + +The worker bundle gains `readable-stream` and its small browser dependency closure. In return, stream state and backpressure remain maintained upstream instead of becoming local compatibility code. + +Watcher event timing is deterministic from VFS commits rather than inherited from an operating-system backend. This stays within Node's watcher contract, which does not guarantee native event coalescing, while tests pin every event distinction the current consumers require. diff --git a/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md new file mode 100644 index 0000000000..4470720e1f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md @@ -0,0 +1,80 @@ +# Agent Note: Web Worker VFS 监听与 CLI 兼容 confinement + +Status: implemented + +[English](2026-08-23-webworker-vfs-watch-and-landlock.md) | 中文 + +## Problem + +Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。缺少 VFS 变更源时,拒绝 `node:fs.watchFile` 会让 `skill-filesystem` 返回不完整观测并在每次查询时重新扫描,而无事件的成功调用会让已有根永远等待 Chokidar 的 `ready`。Settings 和 credentials 同样需要真实的外部编辑事件,而不是包专用 fake。 + +同一组合挂载 `sandbox-local`,其 Linux 选择链依次探测 bwrap 和 `@deepseek-ai/node-addon-landlock-run`。Worker 无法执行这两个二进制文件。如果选择链到此结束,`workspace-write` 与 `read-only` 将不可用,尽管 shell 的每项文件系统操作已经经过 Host 侧 VFS 调用点。 + +文件系统兼容边界遵循 [Worker Node face 决策](2026-08-20-webworker-node-face.zh.md):纯 JavaScript watcher 包在 Node 兼容模块之上保持原样运行。Native 或 binary 包可以保持公开 JavaScript API 与可执行文件协议,同时替换执行后端。无法维持调用方可见 Node 行为的 API 继续明确标记为不可用;`node:vm` 不属于本决策范围。 + +## Decision + +### VFS mutation source 与文件 watcher + +`MemoryVfs` 向任意数量的订阅方发布已提交的 `write`、`mkdir`、`remove` 和 `chmod` mutation。状态改变后才发布,失败操作不发布,镜像 seed 保持无事件,一个抛错的订阅方也不能让文件系统操作失败或阻止其他订阅方。Rename 被表达为源路径删除与包含完整状态的目标 mkdir/write 记录;目标 write 会标记目录项已改变,因此 watcher 报告 `rename`,未来的 durable sink 同时拿到物化目标所需的字节。目录的直接条目集合改变时,其 mtime 会推进,因此 polling 能像 Node 一样发现子项创建和删除。 + +Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。 + +`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。预先 abort 的 callback watcher 先返回对象、再异步关闭;预先 abort 的 promise watcher 在第一次读取 iterator 时以 `AbortError` 拒绝。 + +`fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。 + +### Stream 与未修改的 NPM 包 + +`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。Descriptor 在 rename、replacement 和 unlink 后仍保留打开时的文件身份与访问模式;hard link 共享该身份及后续内容和 mode 变化,truncate 增长则用零字节填充。 + +Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。 + +### 基于逐进程 VFS 授权的 Landlock CLI + +`@deepseek-ai/node-addon-landlock-run` 是普通镜像依赖,不是模块 replacement。其未经修改的 JavaScript 入口通过 Worker 实现的 `node:child_process`、`node:module`、`node:path` 与 `node:url` 运行,因此该包仍是 `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`、`launcherPath()`、`grantArgs()` 和 `probe()` 的唯一所有者。镜像可以包含匹配的 Linux optional package,但包解析不决定 Worker 平台是否提供 Landlock;缺少该 optional package 时,入口包产生的确定性 fallback 路径仍到达同一个平台可执行文件实现。 + +进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。 + +每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。Grant root 在 containment 检查前去除尾部分隔符。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。 + +Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。 + +### 明确延后的行为 + +`node:vm`、`node:worker_threads`、`node:net`、`node:sqlite`、native PTY、Sharp 和 ripgrep 不属于本次变更。VFS 仍然只支持 POSIX、内存存储且没有符号链接。Browser Worker 没有 libuv 风格的引用计数事件循环,因此 watcher 的 `persistent`、`ref()` 和 `unref()` 保留 API 与可观察状态,但不能决定 Worker 生存期。 + +## Alternatives considered + +**在 Worker profile 中禁用 watcher 与 sandbox 配置项。** 缩减组合后将不再测试相同的 Host tree,还会隐藏 preview 部署特有的包集成故障。 + +**让 `watchFile` 成为无事件的成功调用。** 缺失根永远无法推进,已有根则会永久等待 Chokidar `ready`。 + +**只从 `node:fs` 通知 watcher。** Shell 进程请求以及直接写 VFS 的实现可以绕过通知点。只有提交状态的 `MemoryVfs` 才是完整真源。 + +**保留 VFS 专用的 Chokidar replacement。** 这会重复实现上游已经维护的目录扫描、ready 计数、写入稳定等待、原子替换、共享 watcher 所有权和 teardown。 + +**用 Worker 模块替换 Landlock 入口包。** 重新实现其导出常量、授权参数构造、launcher 解析和 probe,会为一个已经能在 Worker Node 兼容层上运行的包约定建立第二份副本。只有平台可执行文件实现需要不同。 + +**只识别一个精确 launcher 路径。** Optional dependency 的安装状态与入口包已有的 fallback 会为同一个可执行文件产生不同的绝对路径。包管理器布局不是平台能力的身份,因此可执行文件分发使用逻辑名称 `landlock-run`。 + +**在 `sandbox-local` 中增加 Worker 分支。** 这会把策略到授权的映射复制到业务包中。解释现有 launcher 协议可以保持 provider、consumer、配置、诊断和 native 包 API 不变。 + +**在全局 VFS 上保存一个当前策略。** 并发前台、后台和升权命令会覆盖彼此的权限。授权必须归属于单个进程句柄及其文件系统适配器。 + +## Verification + +- `fs-watch-stream.spec.ts` 对照当前 Node 版本验证缺失、创建、修改、删除的 `watchFile` 状态转换,以及文件流生命周期、分片、范围、backpressure、字节计数、默认值和 abort 身份。 +- `chokidar.spec.ts` 通过 Worker transformer 与模块 loader 加载 lockfile 选定的两组 Chokidar 和 readdirp 依赖,并在 `MemoryVfs` 上验证 `ready`、callback watcher、polling、缺失文件创建、删除和完全停稳的关闭。 +- `image-loadable.spec.ts` 打包并加载真实的 `@deepseek-ai/node-addon-landlock-run` JavaScript,验证它不在 replacement 表中,并让其 fallback `launcherPath()` 与 `probe()` 经过 Worker 平台可执行文件。`child-process.spec.ts` 与 `sandbox-stack.spec.ts` 随后通过生产 sandbox 和 subprocess 包验证 launcher 失败码、错误 argv 与授权失败、`/tmp` 与 `/dev/null`、rename 拒绝、三种权限模式和逐进程并发授权。 +- `preview-boot.e2e.ts` 构建并启动打包后的浏览器部署,创建 Workspace 与 Session,把缺失的 skill 根逐级推进到可用的 Chokidar watch,读取 catalog,并在没有 watcher 警告的情况下完成 settings 与 credential 写入。 + +## Consequences + +Preview 现在可以在不 fork 源码的情况下运行 NPM watcher 消费方;Host 代码与 shell 进程 Worker 产生的文件系统 mutation 共享同一个有序提交源。WebFS/OPFS 集成仍是围绕该同步权威的异步 mirror,并消费同一个变更源;它不会增加另一份 Chokidar 实现或互相竞争的 mutation 协议。 + +Worker `read-only` 与 `workspace-write` 在不 fork Landlock NPM 包的情况下保留产品权限词汇和拒绝报告。其安全结论比 native Landlock 更窄,但完整覆盖 Worker 执行世界;任何新的文件系统消息或 shell 程序都必须继续经过受 guard 保护的 `ShellFileSystem`。Native-backed 包遵循同一所有权规则:其 JavaScript 保持上游实现,Worker 平台只替换背后的 native artifact。 + +Worker bundle 增加 `readable-stream` 及其少量浏览器依赖。相应地,stream 状态和 backpressure 继续由上游维护,不成为本地兼容代码。 + +Watcher 事件时序由 VFS 提交确定,而不是继承操作系统后端。Node watcher 约定本身不保证 native 事件合并方式,因此该实现仍符合约定;测试固定当前消费方依赖的每一种事件区别。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml index d5c2e38a49..a897753a57 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.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-07-29-atomic-web-image-admission.md -2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6 -2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3 +2026-07-29-atomic-web-image-admission.md: dd2faf1e14c6147c80bcba571d5310899c2e8e22 +2026-07-29-atomic-web-image-admission.zh.md: 8f15f8848dcb38fe6be178b86082a72b4ff0c8eb diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md index c09d376f10..dd2faf1e14 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -6,24 +6,24 @@ English | [中文](2026-07-29-atomic-web-image-admission.zh.md) ## Problem -Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history. +Image prompt admission and `session.selectModel` each cross asynchronous model and attachment lookups. Without one ordering point, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target. Selection could also change the route after admission had begun but before the durable message event was published. ## Decision -Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. +Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot create this ordering conflict. -The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction. +The chain gives the two operations a deterministic order. When selection runs first, later image admission observes the selected model and refuses an unsupported image before persistence. When image admission runs first, its attachment and event publication complete before selection changes the route. The shared LLM runtime can then project durable image blocks to deterministic text placeholders for a text-only request without rewriting the session log. Steering uses the same admission chain even though it does not enter the queued UI mirror. Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. ## Alternatives considered -**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route. +**Scan durable or derived history before selection.** This prevented a text-only route from being selected whenever history contained an image. Request-local projection now supports that route directly, so history is no longer a selection constraint. -**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state. +**Track pending publication separately.** A queued occurrence could be retained from dequeue through its matching event. The promise chain already keeps selection behind the complete admission operation, so a second lifecycle mirror is unnecessary. **Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race. ## Consequences -An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains. +An image prompt and a concurrent model selection have deterministic order. Selection may wait for in-flight image admission, while unrelated text prompts retain their existing concurrency. Text-only model selection remains available after images enter durable history because request assembly projects those images to placeholders. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md index 8785f7489b..8f15f8848d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -6,24 +6,24 @@ Status: implemented ## 问题 -包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态,但即使压缩(compaction)已经从当前模型历史中移除图片,仍会永久阻止选择纯文本目标。 +包含图片的提示词准入与 `session.selectModel` 都会跨越异步模型查询和附件查询。没有统一的排序点时,包含图片的提示词可能在支持图片的目标上通过校验,并发选择却设置了纯文本目标。选择也可能在准入已经开始、持久消息事件尚未发布时改变路由。 ## 决策 -每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 +每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会产生这类排序冲突。 -待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录(steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,转为空闲状态会移除这些条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 +该链为两个操作提供确定顺序。模型选择先执行时,后续图片准入会看到已选模型,并在持久化之前拒绝不支持的图片。图片准入先执行时,附件和事件会在模型选择改变路由之前完成发布。之后,共享 LLM 运行时可以在纯文本请求中把持久图片块投影为确定的文本占位符,无需改写会话日志。steering 不进入排队 UI 镜像,但仍使用同一条准入链。 提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 ## 曾考虑的替代方案 -**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。 +**选择前扫描持久历史或派生历史。** 这会在历史包含图片时阻止选择纯文本路由。请求期投影已经可以直接支持该路由,因此历史不再是选择约束。 -**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。 +**单独跟踪待发布状态。** 排队条目可以从出队一直保留到匹配事件发布。promise 链已经让模型选择等待完整的准入操作,因此不需要第二套生命周期镜像。 **序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。 ## 后果 -包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。 +包含图片的提示词准入与并发模型选择之间具有确定顺序。模型选择可能等待正在进行的图片准入完成,无关的纯文本提示词仍按现有方式并发处理。图片进入持久历史后仍可选择纯文本模型,因为请求组装会把图片投影为占位符。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index cc523de6ac..7593352560 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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-07-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 5e9b5c254d54a7eb189f353f44cd6eb18c1bc2df -2026-07-29-human-transcript-append-origin.zh.md: 51cf6e2f122f8f452213ce1d5ed02bb1a7eeba70 +2026-07-29-human-transcript-append-origin.md: 72cafb5a1a149bcdb73d885d4e4fc4ac01e48cd2 +2026-07-29-human-transcript-append-origin.zh.md: 79fcc56b7b60082ebd2946d70419a659ff5687b7 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 5e9b5c254d..72cafb5a1a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -30,7 +30,7 @@ The terminal's [archived live compaction progress decision](../../archived/featu ## Alternatives considered -**Recognize a checkpoint by shape (a replacement `user/message`).** Rejected: it reads a coincidence of today's producers instead of a declared contract, and any future producer that replaces a range with a user message would silently inherit the compaction marker. The seam already publishes `COMPACT_CHECKPOINT_SOURCE` precisely so consumers can recognize a checkpoint independently of the backend. +**Recognize a checkpoint by shape (a replacement `user/message`).** Rejected: it reads a coincidence of the shipped producer set instead of a declared contract, and any future producer that replaces a range with a user message would silently inherit the compaction marker. The seam already publishes `COMPACT_CHECKPOINT_SOURCE` precisely so consumers can recognize a checkpoint independently of the backend. **Keep rendering the checkpoint as an injected-context card.** Rejected: the framed checkpoint is an instruction envelope written for the model, not human conversation content. Showing it while hiding the history it replaced inverts what the reader needs. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 51cf6e2f12..79fcc56b7b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -30,7 +30,7 @@ Status: implemented ## 曾考虑的替代方案 -**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是当前生产者的巧合而非已声明的约定,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。seam 已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。 +**按形态识别检查点(一个替换型 `user/message`)。** 被否决:那读取的是已交付生产者集合的巧合而非已声明的约定,而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。seam 已经发布 `COMPACT_CHECKPOINT_SOURCE`,正是为了让消费方与后端无关地识别检查点。 **继续把检查点渲染为注入上下文卡片。** 被否决:带框的检查点是为模型撰写的指令信封,不是人类对话内容。展示它却隐藏它替换掉的历史,正好颠倒了读者的需要。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index a33ceea5e1..deb8efc160 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 102bdab27eddab8f3f61390b0025c0d6ce0c3edc -2026-07-30-web-transcript-log-ordered-projection.zh.md: d7f6c980db1eeb58fa1315b50ecbd2a62e81e86c +2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a +2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 102bdab27e..ed6fa4df3a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. +Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/ui-chat/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. @@ -51,9 +51,9 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b **Value-import the predicate** from the new leaf and add `dsh-compaction` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf. -**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk. +**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk. -**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate. +**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s, so it means a wire contract change out of proportion to one pure predicate. **Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index d7f6c980db..cf8dc35099 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' ``` -重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 +重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/ui-chat/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 +`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 @@ -51,9 +51,9 @@ const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' **从新叶子值导入该谓词**,并把 `dsh-compaction` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id,不是谓词——一个类型就够了,而被擦除的导入根本不会抵达纯度门禁,因此无需向它放行任何东西。白名单只在值导入时才有意义,而在那里它是笔糟糕的交换:`INLINE_SAFE` 按模块说明符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行。 -**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。 +**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。 -**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议约定变更——为一个纯谓词付出的代价不成比例。 +**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端折叠的是原始 `SessionEvent`,因此这意味着一次线协议约定变更——为一个纯谓词付出的代价不成比例。 **把冻结节点的归属移进适配器**(`nodes(extraNodes)`),像那个未合并分支所做的那样。已拒绝:被打断的节点来自 `Session` 已经在窗口上运行的 `turn/end` 清扫,而在按 seq 单调的记录之上,简单形态就是正确的——适配器返回节点,会话按 seq 归并冻结节点。加宽适配器签名什么也换不到,还会把清扫与它的产物拆开。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index 4436b5ead9..a96ce55a01 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.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-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: d01231f706a7d3850ce1b3770ef351cb7e211384 -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 831e2434324ce0dea0e433bbede07e5536def962 +2026-07-31-composer-text-layers-share-one-scrollport.md: 4989ab3bcedcf0ea29b95b565b2e0756ab9d5b62 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 95b6d3055775f733f6c700823ce307869d1c0961 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index d01231f706..4989ab3bce 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -48,7 +48,7 @@ Revealing the caret is the one thing that now depends on the browser rather than **Scroll both layers from JavaScript, with the textarea `overflow: hidden` and a wheel handler assigning both offsets in one task.** No divergence during wheel gestures, since nothing scrolls without us. Rejected because it replaces native scrolling — momentum, trackpad rubber-banding, scrollbar dragging, keyboard scrolling — with a hand-written approximation, and the caret-reveal path (the browser setting the textarea's own offset) still lands asynchronously. -**Keep the cap on the mirror and just wrap today's structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything. +**Keep the cap on the mirror and just wrap the existing structure in a scroller.** The layers would stay window-sized, not draft-sized: `inset: 0` on an absolutely positioned child resolves against the scrollport's padding box, not its scrollable overflow area, so both layers would scroll away from the content that is supposed to be underneath them. The stack has to be the full draft height for the arrangement to mean anything. **Give the backdrop `overflow: auto` and let it scroll itself.** It would then have an offset of its own to keep in step, which is the same problem plus a second scrollbar painted over the input. The backdrop is a projection of the textarea, not an independently navigable surface. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 831e243432..95b6d30557 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -48,7 +48,7 @@ Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的 **两层都由 JavaScript 驱动滚动:textarea 设 `overflow: hidden`,滚轮处理器在同一个任务里给两个偏移赋值。** 滚轮手势期间不会分离,因为没有我们就没有东西会滚动。被否决,是因为它用手写近似替换了原生滚动——惯性、触控板回弹、拖拽滚动条、键盘滚动——而且光标回视路径(浏览器设置 textarea 自己的偏移)仍然是异步落地的。 -**把上限留在镜像层上,只在今天的结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。 +**把上限留在镜像层上,只在既有结构外面套一个滚动容器。** 那样两层仍是「窗口大小」而非「草稿大小」:绝对定位子元素的 `inset: 0` 是相对滚动容器的 padding box 解析的,而不是相对其可滚动溢出区域,于是两层会从本该垫在它们下面的内容上滚开。栈必须与整份草稿等高,这套排布才有意义。 **给 backdrop 加 `overflow: auto`,让它自己滚动。** 那样它就有了一个自己的偏移需要保持同步,即同一个问题再加一条画在输入框上的滚动条。backdrop 是 textarea 的投影,不是一个可独立导航的界面。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml index 5bbf2c832c..14f569f1f7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.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-08-02-goal-round-wrapup-message.md -2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b -2026-08-02-goal-round-wrapup-message.zh.md: a39f1e7d5705b95fe078b75198d885881842d07c +2026-08-02-goal-round-wrapup-message.md: 22a7d329d28ec989faa890c1b3687ec8b96d4a86 +2026-08-02-goal-round-wrapup-message.zh.md: fb62391655d7e1c2b09207765cf307edde6d5260 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md index c6bc3d5912..22a7d329d2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md @@ -22,7 +22,7 @@ Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-rep ## Alternatives considered -- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. +- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. - **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn. - **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md index a39f1e7d57..fb62391655 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md @@ -22,7 +22,7 @@ Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。 ## 曾考虑的替代方案 -- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 +- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 - **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。 - **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧约定和工具的可回放值搅在一起。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml index b936c379ef..b4da830df6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.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-08-04-composer-tab-gutter-reservation.md -2026-08-04-composer-tab-gutter-reservation.md: 8bd9fb2d86982d82b44a82c55b7303fcd9a5bf4d -2026-08-04-composer-tab-gutter-reservation.zh.md: d3a9691f5f5fb095fc53b4cf6b627025220a8148 +2026-08-04-composer-tab-gutter-reservation.md: 37a07b2b98bc676ff9c11fe90bc8e346f6189690 +2026-08-04-composer-tab-gutter-reservation.zh.md: bd6a7899515505017549960c2a5769e9ab076ca5 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md index 8bd9fb2d86..37a07b2b98 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md @@ -36,7 +36,7 @@ The reservation is worth what it costs only because the bar takes layout space h - Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column. - The card holds one position across three transitions, by two mechanisms: the reservation keeps Chat's seat at one width across its own phases (short ↔ scrolling transcript, hero ↔ first scrolling turn), and the overlay seat's compensation matches it on the Chat ↔ Trajectory transition ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)). -- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. +- The overlay state is now a scroll container. No shipped content can overflow it; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one. - The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md index d3a9691f5f..bd6a789951 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.zh.md @@ -36,7 +36,7 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟 - Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。 - 卡片在三种切换下保持同一位置,由两种机制达成:预留让 Chat 的座位在自身各相位间保持同一宽度(transcript 较短 ↔ 可滚动、hero ↔ 第一个可滚动轮次),Chat ↔ Trajectory 的切换则由覆盖座位的补偿来对齐([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.zh.md))。 -- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 +- overlay 状态现在是一个滚动容器。其中没有已交付内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。 - 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。 ## 测试 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml index 9f352def8f..a148348ba4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.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-08-05-workspace-blank-session-reuse-membership.md -2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722 -2026-08-05-workspace-blank-session-reuse-membership.zh.md: 6350c6773265edebb381d5ca10fd5126a5722a5a +2026-08-05-workspace-blank-session-reuse-membership.md: df8cc898f5a80937b8aac69ebd51a6a93882971b +2026-08-05-workspace-blank-session-reuse-membership.zh.md: 73ccdbbb002920eea8f7b01bc0fa8a18859bd3ee diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md index 0d46a0ccf6..df8cc898f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md @@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them) ## Testing -`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md index 6350c67732..73ccdbbb00 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.zh.md @@ -26,4 +26,4 @@ Status: implemented ## 测试 -`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 +`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml index a28a57cfd5..841f1c8442 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.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-08-06-reader-scroll-attribution-observed-top-ledger.md -2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c -2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 6aa1866e802b00d0a3431dfd5bef9efd57121294 +2026-08-06-reader-scroll-attribution-observed-top-ledger.md: b55bbc39f6e1f24bb7751b7743da23736abbd06b +2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: e38ead6c9b80e41b37556172bd6c1f411858b6ed diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md index 66a1ca361c..b55bbc39f6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md @@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the ## Testing -Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. +Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger. The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md index 6aa1866e80..e38ead6c9b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md @@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输 ## 测试 -`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 +`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml index 2a9e6f4e43..7ef0a2a2f9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.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-08-06-token-surface-unpriced-replace-compatibility.md -2026-08-06-token-surface-unpriced-replace-compatibility.md: cf672a426f20fa65aea9b7b6391a002d41631b4c -2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: 2fc269ebac0e0179e6bea525bec7df915996bc7e +2026-08-06-token-surface-unpriced-replace-compatibility.md: a271d3697c4ca27fcc2bb30358eb54dfe2b05ad8 +2026-08-06-token-surface-unpriced-replace-compatibility.zh.md: 21145723deb690c72bc8341f99efab8eb13e857b diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md index cf672a426f..a271d3697c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.md @@ -26,7 +26,7 @@ Both projections share the one fold, so neither gains state fields nor bumps its ## Consequences -An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. The error direction is safe — overestimating occupancy at worst invites an earlier compaction. +An unpriced replacement holds the total still instead of shrinking it, so the compacted-away span stays counted: `contextBreakdown.messageTokens` retains the overcount, and `contextPressure.projectedTokens` overestimates occupancy only until the next usage sample re-anchors it, because that figure tracks movement since the sample rather than the absolute level. Overestimating occupancy can only trigger an earlier compaction. The loud failure survives where it still means something: a range-mismatched adjacent claim is a current producer bug and still throws. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md index 2fc269ebac..21145723de 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-token-surface-unpriced-replace-compatibility.zh.md @@ -26,7 +26,7 @@ Status: implemented ## 影响 -未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。误差方向是安全的:高估占用率最坏不过是招致一次更早的压缩。 +未计价的替换让总量保持不动而不是缩小,因此被压缩(compaction)掉的区段仍被计入:`contextBreakdown.messageTokens` 保留这部分多计的量;`contextPressure.projectedTokens` 会高估占用率,但只持续到下一个用量样本重新锚定为止,因为该数字追踪的是自样本以来的增减,而非绝对水平。高估占用率只可能触发更早的压缩。 响亮失败保留在它仍有意义的地方:区间不匹配的相邻声明是现行生产方的缺陷,仍会抛出异常。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index e88dae93a6..bad485523f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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-08-10-minimal-preset-owns-rl-composition.md -2026-08-10-minimal-preset-owns-rl-composition.md: 65d24f9a03eedffac34f0c0141a8e2f643a48b7b -2026-08-10-minimal-preset-owns-rl-composition.zh.md: 983fea50dfd4a262204b68937d07910c69018614 +2026-08-10-minimal-preset-owns-rl-composition.md: 47a1bbe9f8f4875e2437b3ff955663c4b3de7dce +2026-08-10-minimal-preset-owns-rl-composition.zh.md: 0fab1e1f23a314bec80b5fd355abcb2881c1b1a5 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md index 65d24f9a03..47a1bbe9f8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -22,7 +22,7 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at System-prompt and persona package tests prove final complete-section and runtime-context suppression, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web-orientation text, dynamic policy contexts, and a test section are registered, asserts that no runtime-context snapshot exists, the entry-local filesystem is bare, and compaction is absent, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. -The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. +The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md index 983fea50df..0fab1e1f23 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -22,7 +22,7 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它 系统提示词与 persona 包测试证明了 complete 段最终约束与 runtime-context 抑制,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web 定位文本、动态策略上下文和一个测试段落;它断言不存在 runtime-context 快照、entry 本地文件系统是裸后端且压缩不存在,随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 -独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 +独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.i18n.yaml index d5045408e2..06746a97a8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.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-08-12-composer-overlay-seat-width-compensation.md -2026-08-12-composer-overlay-seat-width-compensation.md: 0ec4d1272ac1adab5b724dccf44f567e15cc3368 -2026-08-12-composer-overlay-seat-width-compensation.zh.md: e4a56e5bfa5db65cfb68fb9da48c055678434c44 +2026-08-12-composer-overlay-seat-width-compensation.md: 35f1e25c2814d4123a7b70e1ba3fd35c90034da5 +2026-08-12-composer-overlay-seat-width-compensation.zh.md: 7979f25f2ff864f2212a675a2a8811043cdb482a diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md index 0ec4d1272a..35f1e25c28 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md @@ -24,7 +24,7 @@ The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh **Accept the 4px card shift.** Dropping the reservation without compensating the seat would move the input card on every tab switch, which is exactly the symptom the earlier note fixed. Rejected: the card position is a deliberate cross-tab invariant. -**Inset the overlay seat by the bar's width.** The [gutter-reservation note](2026-08-04-composer-tab-gutter-reservation.md) rejected exactly this, and this note adopts it; what changed is the rejection's premise. The number was the engine's, not ours — the WebKit path draws the sheet's 8px bar while the Firefox path draws whatever `scrollbar-width: thin` resolves to — so a hardcoded inset would line the two states up in Chromium and drift elsewhere. The overlay branch reserved an engine-resolved gutter of its own back then, so an inset had to match that width exactly. Today the overlay branch reserves nothing, so the compensation is the overlay side's only mechanism, and the literal half of the rejection is answered by making the 8px a variable that mirrors the `::-webkit-scrollbar` rule in the same diff. The Firefox half remains: Chat reserves the engine-resolved width while the compensation stays fixed, and the residual drift where the two differ is recorded as an accepted cost in Consequences. +**Inset the overlay seat by the bar's width.** The [gutter-reservation note](2026-08-04-composer-tab-gutter-reservation.md) rejected exactly this, and this note adopts it; what changed is the rejection's premise. The number was the engine's, not ours — the WebKit path draws the sheet's 8px bar while the Firefox path draws whatever `scrollbar-width: thin` resolves to — so a hardcoded inset would line the two states up in Chromium and drift elsewhere. The rejected design assumed an engine-resolved gutter in the overlay branch, so an inset had to match that width exactly. The overlay branch reserves nothing, so the compensation is the overlay side's only mechanism, and the literal half of the rejection is answered by making the 8px a variable that mirrors the `::-webkit-scrollbar` rule in the same diff. The Firefox half remains: Chat reserves the engine-resolved width while the compensation stays fixed, and the residual drift where the two differ is recorded as an accepted cost in Consequences. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.zh.md index e4a56e5bfa..7979f25f2f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.zh.md @@ -24,7 +24,7 @@ trajectory 表格让这个代价显形:整行分隔线在面板右边缘前 8p **接受 4px 卡片位移。** 去掉预留却不补偿座位,会在每次切换标签页时移动输入卡——正是前一份 note 修复的症状。已拒绝:卡片位置是刻意保持的跨标签页不变量。 -**把 overlay 座位按滚动条宽度内缩。** [滚动条槽预留 note](2026-08-04-composer-tab-gutter-reservation.zh.md) 当初否决的正是这个方案,本 note 采纳了它;变的是否决的前提。这个数字属于引擎而不属于我们——WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度——因此硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。当初 overlay 分支自己预留的是引擎解析出的槽宽,内缩必须精确匹配那个宽度。如今 overlay 分支不预留任何槽位,补偿成为覆盖侧唯一的机制;否决的字面量那一半,通过把 8px 变成与 `::-webkit-scrollbar` 规则同处一个 diff 的变量来回应。Firefox 那一半仍然存在:Chat 预留引擎解析宽度,补偿保持固定 8px,两者不等之处的残余漂移作为接受的代价记录在后果中。 +**把 overlay 座位按滚动条宽度内缩。** [滚动条槽预留 note](2026-08-04-composer-tab-gutter-reservation.zh.md) 当初否决的正是这个方案,本 note 采纳了它;变的是否决的前提。这个数字属于引擎而不属于我们——WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度——因此硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。被否决的设计假定 overlay 分支预留引擎解析出的槽宽,内缩必须精确匹配那个宽度。overlay 分支不预留任何槽位,补偿成为覆盖侧唯一的机制;否决的字面量那一半,通过把 8px 变成与 `::-webkit-scrollbar` 规则同处一个 diff 的变量来回应。Firefox 那一半仍然存在:Chat 预留引擎解析宽度,补偿保持固定 8px,两者不等之处的残余漂移作为接受的代价记录在后果中。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml index d84f437915..c7289d869f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.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-08-13-feedback-note-editor-popover.md -2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800 -2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51 +2026-08-13-feedback-note-editor-popover.md: 42c08e39cfb054db689503e23306c5049a97b6cb +2026-08-13-feedback-note-editor-popover.zh.md: 553029f8a42dae42a38e909d716b41e2c6dd252e diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md index 33b7cd84b9..42c08e39cf 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md @@ -14,7 +14,7 @@ The same stylesheet also named four `--dsw-alias-*` tokens that the theme does n ## Decision -The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out. +The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. `ui-primitives/useAnchoredPosition` owns the shared measure-offset-clamp-and-track behavior. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out. **The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward. @@ -24,7 +24,7 @@ The note editor does not enter the row's flex layout at all. It is a popover: a ## Alternatives considered -**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not. +**Inline expansion on the row, with the editor claiming its own line through a full-width flex basis and wrapping.** Rejected: it fixes the geometry (the row reports zero overflow from 1680px down to 600px) but makes the branch action and end clock wrap below the editor, expands the row to three lines, and puts the interaction in the same horizontal strip the row already fills. [Issue #2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reports the resulting misalignment and requests the popover pattern the chat surface already uses. A popover removes the editor from the row, leaving the strip and keyboard tab order unchanged whether the editor is open or not. **An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do. @@ -40,4 +40,4 @@ The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub. -A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change. +A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. Undefined design-token references remain in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; the repository has no gate that rejects them. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md index b130ab1e9c..553029f8a4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md @@ -14,7 +14,7 @@ Status: implemented ## Decision -备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。 +备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。`ui-primitives/useAnchoredPosition` 负责共享的「测量—偏移—钳制—跟随」行为。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。 **操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。 @@ -24,7 +24,7 @@ Status: implemented ## Alternatives considered -**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。 +**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行。**否决:这种做法能修正几何(行在 1680px 到 600px 报告零溢出),但会让 branch 操作与末尾时钟在编辑器打开时换行到编辑器下方,使行占据三行,并让交互与行本就占满的横向条带争空间。[Issue #2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561)记录了由此产生的错位,并要求采用 chat 界面已有的浮层模式。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。 **不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。 @@ -40,4 +40,4 @@ Status: implemented 有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。 -520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。 +520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。`ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 仍引用未定义的 design token;仓库没有拒绝此类引用的门禁。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml index 3be0c200fd..887950ad94 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.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-08-15-max-token-replay-state-alignment.md -2026-08-15-max-token-replay-state-alignment.md: 256a64403a08377cf35ba645175698678eaa7f8b -2026-08-15-max-token-replay-state-alignment.zh.md: f7a7280ab7657124541d93e3c592eab9ec75d28e +2026-08-15-max-token-replay-state-alignment.md: 0d8c9f2e55d826769325ea489d797a8561ac0e8e +2026-08-15-max-token-replay-state-alignment.zh.md: 30f11b990abbe1ddfb36241b2f80c188de75ed22 diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md index 256a64403a..0d8c9f2e55 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md @@ -12,7 +12,7 @@ pi-ai recorded one opaque replay blob per response, projected from the provider' Two changes, one per side of the durable boundary. -**Write side — one keep/drop decision.** The finish chunk's `replayState` becomes a typed `ReplayEnvelope`: an opaque `response` half plus optional opaque per-block entries aligned with the emitted block sequence. `BlockAssembler` computes its keep/drop decision once and applies it to blocks and envelope entries together, so any transformation assembly performs — today's max-token tool-call drop or a future one — prunes the matching metadata by construction. Retained blocks keep their entries, so a truncated response keeps signatures for the reasoning and text it kept. An envelope whose entries do not match the emitted block count is discarded whole (a misemitting adapter must not publish misattributed metadata). pi-ai splits its former flat state into a version-2 response half and per-block signature entries. +**Write side — one keep/drop decision.** The finish chunk's `replayState` becomes a typed `ReplayEnvelope`: an opaque `response` half plus optional opaque per-block entries aligned with the emitted block sequence. `BlockAssembler` computes its keep/drop decision once and applies it to blocks and envelope entries together, so any transformation assembly performs — the max-token tool-call drop or a future one — prunes the matching metadata by construction. Retained blocks keep their entries, so a truncated response keeps signatures for the reasoning and text it kept. An envelope whose entries do not match the emitted block count is discarded whole (a misemitting adapter must not publish misattributed metadata). pi-ai splits its former flat state into a version-2 response half and per-block signature entries. **Read side — durable content is authoritative.** `toPiAssistant` treats replay state as fidelity metadata, not as a load-bearing input: any state the reading build cannot use — another adapter's kind, another version (including the flat version-1 form already on disk), malformed metadata, or a block shape that no longer matches the content — degrades that one message to the existing foreign provider-neutral conversion and reports the `INVALID_REPLAY_STATE` diagnostic through the plugin's `onReplayDegrade` hook (a logger warning). The request proceeds. This is what lets sessions poisoned before this change continue instead of erroring forever, and it bounds every future divergence source to a fidelity loss on one message. @@ -22,7 +22,7 @@ Assembler unit tests prove pruning, misalignment discard, and pass-through for u ## Alternatives considered -**Suppress the whole replay state when assembly drops a tool call.** Works for today's one transformation, but re-derives the drop condition beside `blocks()` (the two drift silently), discards valid signatures for the retained blocks, and leaves read-time divergence — legacy sessions on disk foremost — a hard error. +**Suppress the whole replay state when assembly drops a tool call.** Works for the one shipped transformation, but re-derives the drop condition beside `blocks()` (the two drift silently), discards valid signatures for the retained blocks, and leaves read-time divergence — legacy sessions on disk foremost — a hard error. **Keep the state and relax pi-ai's block-count validation to attach what fits.** Rejected: index-aligned signatures attached to a different block list would present false native history to the provider. Degrading attaches nothing. diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md index f7a7280ab7..30f11b990a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md @@ -12,7 +12,7 @@ pi-ai 为每个响应记录一个从提供方原生消息投影而来的不透 两处改动,各覆盖持久化边界的一侧。 -**写侧——一次保留/丢弃决定。** finish 分片的 `replayState` 变为有类型的 `ReplayEnvelope`:一个不透明的 `response` 半区,加上与发射块序列对齐的可选不透明逐块条目。`BlockAssembler` 只计算一次保留/丢弃决定,并把它同时应用于块和逐块条目,因此组装执行的任何变换——今天的 max-token 工具调用丢弃或未来的其他变换——都按构造裁剪掉对应元数据。保留的块保留其条目,所以被截断的响应仍为其保留的推理(reasoning)与文本保有签名。条目数与发射块数不一致的数据整体丢弃(发射不当的适配器不得发布归属错误的元数据)。pi-ai 把原先的平铺状态拆为版本 2 的 response 半区和逐块签名条目。 +**写侧——一次保留/丢弃决定。** finish 分片的 `replayState` 变为有类型的 `ReplayEnvelope`:一个不透明的 `response` 半区,加上与发射块序列对齐的可选不透明逐块条目。`BlockAssembler` 只计算一次保留/丢弃决定,并把它同时应用于块和逐块条目,因此组装执行的任何变换——max-token 工具调用丢弃或未来的其他变换——都按构造裁剪掉对应元数据。保留的块保留其条目,所以被截断的响应仍为其保留的推理(reasoning)与文本保有签名。条目数与发射块数不一致的数据整体丢弃(发射不当的适配器不得发布归属错误的元数据)。pi-ai 把原先的平铺状态拆为版本 2 的 response 半区和逐块签名条目。 **读侧——持久化内容是权威记录。** `toPiAssistant` 把回放状态当作保真度元数据,而非承重输入:读取方无法使用的任何状态——其他适配器的 kind、其他版本(包括已落盘的平铺版本 1 形式)、格式错误的元数据、或与内容不再匹配的块结构——都把这一条消息降级为既有的外来提供方无关转换,并通过插件的 `onReplayDegrade` 钩子(logger 警告)上报 `INVALID_REPLAY_STATE` 诊断。请求继续执行。正是这一点让本次改动之前已被毒化的会话得以继续而不是永远报错,也把未来一切分叉源约束为单条消息的保真度损失。 @@ -22,7 +22,7 @@ pi-ai 为每个响应记录一个从提供方原生消息投影而来的不透 ## 已考虑的替代方案 -**组装丢弃工具调用时抑制整个回放状态。** 对今天唯一的变换有效,但在 `blocks()` 旁边重新推导丢弃条件(两处会无声漂移),丢掉保留块的有效签名,并让读取时的分叉——首当其冲是已落盘的旧会话——仍然是硬错误。 +**组装丢弃工具调用时抑制整个回放状态。** 对唯一已交付的变换有效,但在 `blocks()` 旁边重新推导丢弃条件(两处会无声漂移),丢掉保留块的有效签名,并让读取时的分叉——首当其冲是已落盘的旧会话——仍然是硬错误。 **保留状态并放宽 pi-ai 的块数校验、能贴多少贴多少。** 否决:索引对齐的签名贴到不同的块清单上,会向提供方呈现虚假的原生历史。降级则什么都不贴。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md deleted file mode 100644 index 6e54d030ee..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Refresh blank session permission defaults - -Status: implemented - -English | [中文](2026-08-17-blank-permission-default-refresh.zh.md) - -## Problem - -The Web New Session flow reuses a workspace's blank session instead of minting another hidden placeholder. Permission defaults are pinned into a session at creation time, so changing the General settings permission row after a blank placeholder already existed left that placeholder on the previous preset. The next "new" conversation could therefore reuse a blank session whose permission chip contradicted the newly saved default. - -## Decision - -The Web workspace runtime owns candidate selection: a reusable session must be blank, belong to the selected Workspace, match its canonical cwd, and not be archived. Instead of returning that id directly, `WorkspaceRuntime.connectWorkspace` explicitly adopts it through `session.create` with `reuseWorkspaceBlank: true`. The host rechecks blankness, Workspace membership, cwd, and archive state before notification, and can resume a cold persisted placeholder before notifying optional default owners about the exact eligible session. - -`dsh-permission-presets` records each `permission/preset` origin as `default`, `selection`, or `inferred`. On confirmed reuse, it advances the session to the current `defaultPreset` only when no turn has started, the latest selection is default-origin, and the effective sandbox and approval knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, and independently changed knobs remain pinned. The update goes through the normal preset writer, so durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the source for projections and execution. - -This partially refines the earlier [permission default for new sessions](../feature/2026-07-31-permission-default-for-new-sessions.md) decision: a settings write alone does not mutate an existing session, while the later confirmed reuse of a default-origin Workspace blank may advance it after live or cold adoption. - -## Alternatives considered - -**Disable blank-session reuse after any permission settings change.** Rejected because it would leave extra hidden placeholders and make New Session less deterministic. The existing reuse policy is valuable; only stale permission defaults were wrong. - -**Have the client compare a blank session's permission projection with the Settings row.** Rejected because the workspace runtime would need to understand the permission settings namespace. The client reports only its reuse decision; the permission service owns the default-origin test and update. - -**Scan every live blank session when Settings changes.** Rejected because the live store omits cold persisted placeholders and includes blank sessions that Web cannot reuse, such as archived or non-member sessions. It also cannot distinguish an old default from an explicit selection after restart without a durable origin. - -## Consequences - -A Settings change does not rewrite an existing session. Confirmed New Session reuse may append permission facts to a live or cold default-origin placeholder, which remains blank because blankness is defined by the absence of `turn/start`. Started conversations, ordinary seeded resumes, explicit selections, and sessions outside the Web reuse decision keep their permission. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md deleted file mode 100644 index 171395ade7..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Refresh blank session permission defaults - -Status: implemented - -[English](2026-08-17-blank-permission-default-refresh.md) | 中文 - -## Problem - -Web 新会话流程会复用工作区中的空白会话,而不是不断创建隐藏占位会话。权限默认值在会话创建时被固定到该会话中,因此当某个空白占位会话已经存在后,用户再修改「通用设置」里的权限默认值,这个占位会话仍会保留旧预设。下一次“新”对话复用它时,权限 chip 就会和刚保存的默认设置不一致。 - -## Decision - -Web workspace runtime 负责选择候选会话:可复用会话必须保持空白、属于所选 Workspace、匹配其规范 cwd,并且未归档。`WorkspaceRuntime.connectWorkspace` 不再直接返回该 id,而是通过带 `reuseWorkspaceBlank: true` 的 `session.create` 显式接纳它。host 会在通知前重新检查空白状态、Workspace 成员关系、cwd 与归档状态,并且可以先恢复冷存储中的持久占位会话,再向可选的默认值所有者通知确实符合资格的会话。 - -`dsh-permission-presets` 会把每条 `permission/preset` 的来源记录为 `default`、`selection` 或 `inferred`。复用确认后,只有在会话尚未开始轮次、最近选择来自默认值,并且有效沙箱与审批旋钮仍匹配该选择时,服务才会将它推进到当前 `defaultPreset`。显式选择、由旧旋钮推断或没有来源标记的旧选择,以及独立变更的旋钮都会保持固定。更新仍走常规 preset writer,因此持久的 `permission/preset`、`sandbox/mode` 与 `approval/policy` 事实继续作为投影和执行的来源。 - -这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.zh.md)决策:单独写入设置不会改变既有会话,而 Web 之后确认复用、且权限来自默认值的 Workspace 空白会话可以在 live 或冷接纳后推进。 - -## Alternatives considered - -**权限设置变化后禁用空白会话复用。** 拒绝,因为这会留下额外的隐藏占位会话,并让新会话行为更不确定。既有复用策略有价值;错误只在于权限默认值过期。 - -**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace。客户端只报告自己的复用决定;权限服务拥有默认来源检查和更新。 - -**Settings 变化时扫描所有 live 空白会话。** 拒绝,因为 live store 会漏掉冷存储中的持久占位会话,同时包含 Web 无法复用的空白会话,例如已归档或不属于 Workspace 的会话;重启后若没有持久来源,也无法区分旧默认值与显式选择。 - -## Consequences - -Settings 变更不会改写既有会话。确认的新会话复用可能向 live 或冷存储中、权限来自默认值的占位会话追加权限事实;该会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、普通 seed 恢复、显式选择,以及不在 Web 复用决定中的会话都会保留原权限。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml deleted file mode 100644 index ec3cea9dc2..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md -2026-08-17-image-dimension-admission-limit.md: 027259c0949d142ce8d8af27e7daa2abd54769ab -2026-08-17-image-dimension-admission-limit.zh.md: 38422615aa93b7f1639877d7d3751322c77de1eb diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md deleted file mode 100644 index 027259c094..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: Per-side image dimension admission limit - -Status: implemented - -English | [中文](2026-08-17-image-dimension-admission-limit.zh.md) - -## Problem - -`read_image` durably committed an image and appended its block to session history before any dimension check beyond byte count and total pixels. Deployed model routes reject a request with HTTP 400 when it carries many images and any of them has a side above 2000px. An admitted image rides every later request of its session, so one oversized read poisoned the durable history: the next model request failed, and so did every retry, permanently killing the session. The same gap applied to every other image producer (host uploads, MCP tool images) because admission had no per-side bound at all. - -## Decision - -`ImageAttachmentLimits` carries `maxImageDimension`, enforced during the admission full decode (`detectImage`) as `IMAGE_DIMENSION_TOO_LARGE`, so every producer that commits through the attachment service refuses an oversized image before anything reaches durable history. `LocalAttachmentStore` exposes it as the `maxImageDimension` config field with default `DEFAULT_MAX_IMAGE_DIMENSION = 2000`, the strictest per-side bound deployed routes enforce; deployments with laxer routes raise it from cordis.yml. `read_image` maps `IMAGE_DIMENSION_TOO_LARGE` and `IMAGE_TOO_MANY_PIXELS` to model-facing errors that name the resolved path and the limit and tell the model to downscale and retry — the turn continues as a recoverable tool error. The Web composer surfaces `IMAGE_DIMENSION_TOO_LARGE` with dedicated copy naming the limit. The `read-image-dimension` snapshot scenario replays the refusal keylessly through the assembled app: a 2001x1 workspace fixture, a recoverable tool error, and a completed turn. - -## Alternatives considered - -- **Downscale at admission instead of refusing.** Resampling changes the stored bytes away from what the caller supplied, adds a resampling-quality policy, and hides the limit from the model. Refusal keeps admission a pure gate; the model or user can downscale with full knowledge. Worth revisiting only if refusals prove frequent in practice. -- **Enforce at the provider adapter per route.** Too late: by the time a request is assembled the image is already durable history, so every route and every retry re-fails. Admission is the last point where a provider-rejected image can be kept out. -- **Repair already-poisoned sessions** (drop or replace the oversized block on later requests). Out of scope for this fix; admission prevents new poisonings, and history rewriting needs its own design against the model-visible ⟺ logged invariant. - -## Related - -- [Minimal read_image tool](../feature/2026-08-10-minimal-read-image-tool.md) — the tool whose admission gap this closes. -- [Web image intake and limits alignment](../feature/2026-08-12-web-image-intake-and-limits-alignment.md) — the composer-side surfacing of the same `ImageAttachmentLimits`. - -## Consequences - -- One oversized `read_image` can no longer break a session; the model sees an actionable error and the turn completes. -- Images with a side above 2000px are refused even in compositions whose routes would accept them on small requests; such deployments must raise `maxImageDimension` explicitly. -- Sessions that already carry an oversized image remain broken; this change does not repair existing history. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md deleted file mode 100644 index 38422615aa..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-17-image-dimension-admission-limit.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: 图片单边尺寸准入上限 - -Status: implemented - -[English](2026-08-17-image-dimension-admission-limit.md) | 中文 - -## Problem - -`read_image` 在字节数与总像素之外没有任何尺寸检查,就把图片持久提交并追加进会话历史。已部署的模型路由在请求携带多张图片且其中任何一张单边超过 2000px 时会以 HTTP 400 拒绝整个请求。已接纳的图片会随该会话之后的每次请求发送,因此一次超限读取就毒化了持久历史:下一次模型请求失败,之后的每次重试同样失败,会话被永久杀死。其他图片来源(宿主上传、MCP 工具图片)存在同样的缺口,因为准入完全没有单边上限。 - -## Decision - -`ImageAttachmentLimits` 增加 `maxImageDimension`,在准入完整解码(`detectImage`)中以 `IMAGE_DIMENSION_TOO_LARGE` 强制执行,因此所有经附件服务提交的来源都会在任何内容进入持久历史之前拒绝超限图片。`LocalAttachmentStore` 将其暴露为 `maxImageDimension` 配置项,默认值 `DEFAULT_MAX_IMAGE_DIMENSION = 2000`,即已部署路由强制执行的最严格单边上限;路由更宽松的部署可在 cordis.yml 中调高。`read_image` 把 `IMAGE_DIMENSION_TOO_LARGE` 与 `IMAGE_TOO_MANY_PIXELS` 映射为面向模型的错误,指明解析后的路径与上限并提示缩图重试,本轮以可恢复的工具错误继续。Web 输入框对 `IMAGE_DIMENSION_TOO_LARGE` 给出指明上限的专用文案。`read-image-dimension` 快照场景通过组装后的应用无 key 回放这次拒绝:2001x1 的工作区 fixture、一条可恢复的工具错误、一个正常完成的轮次。 - -## Alternatives considered - -- **准入时缩图而非拒绝。** 重采样会让存储字节偏离调用方提供的内容,引入重采样质量策略,还会对模型隐藏上限。拒绝让准入保持为纯粹的门禁;模型或用户可以在知情的前提下自行缩图。只有当拒绝在实践中频繁出现时才值得重新考虑。 -- **在 provider 适配器按路由强制执行。** 为时已晚:组装请求时图片已是持久历史,每条路由、每次重试都会再次失败。准入是把必然被上游拒绝的图片挡在外面的最后一道关口。 -- **修复已被毒化的会话**(在之后的请求中丢弃或替换超限图片块)。不在本次修复范围内;准入阻止新的毒化,而重写历史需要针对「模型可见 ⟺ 已记录」不变量单独设计。 - -## Related - -- [最小 read_image 工具](../feature/2026-08-10-minimal-read-image-tool.zh.md),本次修复补上的正是该工具的准入缺口。 -- [Web 图片摄入与限制对齐](../feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md),同一组 `ImageAttachmentLimits` 在输入框侧的呈现。 - -## Consequences - -- 一次超限的 `read_image` 不再能弄坏会话;模型看到可操作的错误,轮次正常完成。 -- 单边超过 2000px 的图片即使在其路由本可接受(小请求)的组合中也会被拒绝;这类部署必须显式调高 `maxImageDimension`。 -- 已经携带超限图片的会话仍然是坏的;本次改动不修复既有历史。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml index 15e78356f5..1aef254fee 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.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-08-18-rail-search-outside-click-self-dismissal.md -2026-08-18-rail-search-outside-click-self-dismissal.md: 9893b3a2456b9a592e1feb107d21404e043dee78 -2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 91e343b8843dc02ca9c1be2b145e79beb84e17b7 +2026-08-18-rail-search-outside-click-self-dismissal.md: b9f3aa33658919aa9061c0ec988878fea194de47 +2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 72f19157dbef23c1109f799eca9686931a58156f diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md index 9893b3a245..b9f3aa3365 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md @@ -20,7 +20,7 @@ The outside-click dismissal listener does not mount while the rail gesture is in **Defer listener attachment by a frame or timeout.** A raw delay encodes the symptom (the click arrives "too early") instead of the cause (a gesture is in flight). `searchOnExpand` is already the explicit in-flight state with the correct end point; a frame boundary is neither. -**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click today does not — to fix a problem scoped to one gesture. +**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click does not — to fix a problem scoped to one gesture. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md index 91e343b884..72f19157db 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md @@ -20,7 +20,7 @@ Status: implemented **将监听器挂载延迟一帧或一个定时器。** 裸延迟编码的是症状(点击来得"太早")而非成因(手势正在进行)。`searchOnExpand` 已经是带有正确终点的显式进行中状态;帧边界两者都不是。 -**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而如今完成的点击才会——只为修复一个局限于单个手势的问题。 +**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而完成的点击不会——只为修复一个局限于单个手势的问题。 ## 影响 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md deleted file mode 100644 index 0ec4594888..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: Request-level image payload bound - -Status: implemented - -English | [中文](2026-08-18-request-image-payload-bound.zh.md) - -## Problem - -Every image in session history is base64-inlined into every model request by the pi-ai adapter, so a long session's request body grows monotonically with each admitted image. Gateways cap request-body size; once the accumulated payload crossed such a cap the request was rejected with 413 (`Failed to buffer the request body: length limit exceeded`), and because nothing bounds or trims the assembled request, every retry resent the same oversized body. The session was permanently unusable, and the failure text matched no `classifyPiAiError` rule, so it surfaced as the generic `PI_AI_ERROR`. Admission bounds (per image, per message) cannot prevent this: each image is individually admissible, and the sum still grows without bound. Two screenshots were enough to trigger it in production. - -## Decision - -The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. - -## Offload is conversion, not history - -The placeholder is model-visible but not logged as a session event. It stays within the model-visible ⟺ logged invariant the same way the adapter's other serialization does (`(no output)` fallbacks, text-only folding): the offload locations are a pure function of the logged history and the route configuration, so the exact request remains reconstructable from the session log plus the composition. A logged elision event becomes necessary only when offload decisions gain non-deterministic inputs (for example live gateway feedback), which belongs to the deferred capability-metadata design. - -## Alternatives considered - -- **Fail the request with a clear error instead of offloading.** Keeps the model informed but leaves the session wedged: the user cannot remove images from durable history, so a hard failure at the bound is permanent. Offload keeps the session serviceable, which is the point of the fix. -- **Upload images once and reference them by URL / file id.** Removes the linear body growth entirely and is the right medium-term shape (providers and the internal gateway both document a Files path), but it introduces upload lifecycle management across providers and is far beyond a P0 hotfix. -- **Count the full request body, not only images.** Text and tools contribute little and their sizes are only known after full serialization per protocol; bounding the dominant term with explicit headroom is accurate enough for the failure being fixed and much simpler. Revisit inside the route-capability design. -- **Trim at admission instead.** Admission cannot see future accumulation; only the assembled request knows its total. Admission-side bounds (per-side dimension, bytes) remain as the first layer and are owned by [the dimension-limit note](2026-08-17-image-dimension-admission-limit.md). - -## Related - -- [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). -- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. - -## Consequences - -- An image-heavy long session keeps completing requests. The oldest images are omitted first; the most recent image is omitted only when it cannot fit within the bound. -- Crossing the bound rewrites an early message, so the provider prompt-cache prefix ends at the newly offloaded image until the offloaded prefix stabilizes. -- The bound counts base64 image payload only; deployments must keep it below their gateway's request-body cap with headroom, and the shipped default cannot know a private gateway's cap. -- Route capability metadata driving admission and assembly together (image count, per-image size, request size, provider token formulas) remains deferred design work tracked outside this fix. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md deleted file mode 100644 index 7cdf6bb768..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# Agent Note: 请求级图片载荷上限 - -Status: implemented - -[English](2026-08-18-request-image-payload-bound.md) | 中文 - -## Problem - -pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型请求,长会话的请求体随每张入库图片单调增长。网关对请求体大小设有上限;累积载荷一旦越线,请求被以 413 拒绝(`Failed to buffer the request body: length limit exceeded`),而组装层没有任何约束或裁剪,每次重试都会原样重发同一个超限请求体,会话永久不可用。该报错文本不匹配 `classifyPiAiError` 的任何规则,只能落进笼统的 `PI_AI_ERROR`。准入上限(单图、单消息)无法阻止这一点:每张图片单独看都合规,总和仍然无界增长。线上两张截图即可触发。 - -## Decision - -pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 - -## offload 是转换而非历史 - -占位文本模型可见,但不记录为会话事件。它与适配器的其他序列化(`(no output)` 回退、纯文本折叠)以同样的方式满足「模型可见 ⟺ 已记录」不变量:offload 位置是已记录历史与路由配置的纯函数,确切请求仍可由会话日志加组合配置重建。只有当 offload 决策引入非确定性输入(例如网关的实时反馈)时才需要记录省略事件,那属于暂缓的能力元数据设计。 - -## Alternatives considered - -- **在上限处直接报错而不 offload。** 模型知情,但会话仍然卡死:用户无法从持久历史中删除图片,越线即永久失败。offload 让会话保持可用,这正是本修复的目标。 -- **图片上传一次、按 URL / file id 引用。** 从结构上消除请求体线性增长,是正确的中期形态(各提供方与内部网关都有 Files 路径),但要跨提供方管理上传生命周期,远超 P0 热修复范围。 -- **统计完整请求体而非只统计图片。** 文本与工具占比很小,且其大小要到按协议完整序列化后才可知;对主导项设上限并留出显式余量,对所修故障足够精确且简单得多。留到路由能力设计中再议。 -- **改在准入侧裁剪。** 准入看不到未来的累积,只有组装后的请求知道自己的总量。准入侧上限(单边尺寸、字节)作为第一层保留,归[尺寸上限笔记](2026-08-17-image-dimension-admission-limit.zh.md)所有。 - -## Related - -- [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.zh.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 -- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.zh.md)把这项提供方无关转换应用于官方多模态路由。 - -## Consequences - -- 图片较多的长会话持续可用。最老的图片优先省略;仅当最新图片本身无法装进上限时才会省略它。 -- 越过上限会改写较早的一条消息,提供方 prompt cache 前缀在新被 offload 的图片处截止,直到被 offload 的前缀稳定。 -- 上限只统计 base64 图片载荷;部署必须让它低于自家网关的请求体上限并留出余量,发行默认值无法预知私有网关的上限。 -- 由路由能力元数据同时驱动准入与组装(图片数量、单图大小、请求大小、提供方 token 公式)的设计仍为暂缓工作,在本修复之外跟踪。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml index 0efe5d0ca3..1a3807ef26 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.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-08-20-explicit-web-index-paths.md -2026-08-20-explicit-web-index-paths.md: 6ff08c3c2a18ffd79ef7a048f563a91e41a504da -2026-08-20-explicit-web-index-paths.zh.md: 7b419b9f4c8b4b0de20660b3f70d12e67ab264ff +2026-08-20-explicit-web-index-paths.md: 815cdd81d458547513225f9136a1d23b0f228ccc +2026-08-20-explicit-web-index-paths.zh.md: 4cbbac8413d3c4c8a5dcd25b5979dc0b70d47df5 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md index 6ff08c3c2a..815cdd81d4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.md @@ -20,7 +20,7 @@ GET and HEAD use the same status and content type for index entries, files, and **Use an `Accept: text/html` request header as the fallback rule.** The header expresses representation preference, not whether the pathname is a declared client route. Browser fetches, bots, and monitors may request HTML for an invalid path, so the same false-success behavior remains. -**Add a configurable pathname allowlist now.** No current client route consumes such configuration. A future History API router can add an explicit server rule or configuration field together with the route that requires it, without preserving a speculative public option today. +**Add a configurable pathname allowlist.** No current client route consumes such configuration. A future History API router can add an explicit server rule or configuration field together with the route that requires it, without preserving a speculative public option. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md index 7b419b9f4c..4cbbac8413 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-explicit-web-index-paths.zh.md @@ -20,7 +20,7 @@ GET 与 HEAD 对 index 入口、文件和未命中项使用相同的状态码与 **把 `Accept: text/html` 请求头作为回退规则。** 该请求头表达的是内容表示偏好,而不是 pathname 是否为已声明的客户端路由。浏览器 fetch、机器人和监控都可能为无效路径请求 HTML,因此仍会产生同样的假成功行为。 -**立即添加可配置的 pathname 允许列表。** 当前没有客户端路由消费这项配置。未来的 History API 路由可以在引入所需路由时,同时添加显式服务器规则或配置字段,无需现在保留推测性的公开选项。 +**添加可配置的 pathname 允许列表。** 当前没有客户端路由消费这项配置。未来的 History API 路由可以在引入所需路由时,同时添加显式服务器规则或配置字段,无需保留推测性的公开选项。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml index 99bd30dd67..a25b27d6de 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 9c8684c0510c50c28e8ece53fd794844c771e496 -2026-07-30-versioned-gui-welcome-onboarding.zh.md: d4fe40a1af40008589722b83a5f0751e4f341dde +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md +2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d +2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md new file mode 100644 index 0000000000..43bcc685c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +English | [中文](2026-08-20-plugin-owned-shipped-preset-root.zh.md) + +## Problem + +`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them. The overlay also froze the row's boot-time `config` above every live reload, so no `cordis.patch.yml` edit to the row took effect until restart. Externally reported with an accurate root cause in discussion #3636. + +Under the whole-`config`-replacement patch semantics, any "must survive user layers" value needs enforcement after composition — and review rejected keeping that enforcement in the launcher: `apps/cli` special-casing one plugin's row id, config keys, and precedence is coupling the composition machinery should not carry. + +## Decision + +The shipped presets are the plugin's own. The four built-in compositions moved from `apps/cli/config/agent-presets/` into `packages/preset/agent-presets/presets/`, listed in the package's `files`, and `dsh-agent-presets` resolves `SHIPPED_PRESET_ROOT` relative to its own module — the Loader imports the plugin by package name at runtime, so the directory exists on disk in both the source and installed layouts, the same mechanism that lets the `cordis` preset carry its skills inside its directory. `resolvedRoots` becomes shipped root (`system` trust) unless `includeShippedRoot` is false, then `config.roots` in order, then the derived writable home root unless `includeUserRoot` is false — prepended, so the shipped set always mounts and wins a duplicate id. + +This completes the [per-session preset roster](../architecture/2026-08-03-per-session-agent-presets.md) direction that #2278 started for the writable root: both non-configured roots are now the package's, the launcher composes patch layers with no plugin knowledge, and the squash, the reload freeze, and the dump divergence stop being possible rather than being corrected. The always-load guarantee no longer rides patch ordering: `includeShippedRoot` defaults true in the schema, so a user layer replacing the row's whole `config` keeps the shipped set, and only an explicit `false` — as deliberate as disabling the row — drops it. The compositions bind to the host's agent-plane services, not to the Web surface: no preset row names a client or web plugin, and a host lacking an injected service leaves that row waiting exactly as under any other root. + +## Testing + +`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location. + +## Alternatives considered + +**Keep the launcher patch but derive it per composition, prepending instead of replacing.** The first merged-nowhere iteration of this fix: correct on the squash, the reload freeze, and the dump (which gained the derived layer as a labeled dump layer), with the reporter's overlay-prepend shape as its core. Superseded in review because every variant keeps `apps/cli` special-casing the roster row; the coupling, not the mechanics, was the objection. + +**Have the bundle declare the shipped root itself (`!!js` package-relative path).** Removes the launcher coupling but hangs the always-load guarantee back on patch ordering: a user layer replacing the row's `config` drops the bundle's entry — the reported bug's shape again. + +**Provide the root out of band (a launcher-provided context value the plugin prepends).** The launcher still has to know to provide a preset fact; the special case survives in a different channel. + +## Consequences + +`config.roots` is purely deployment-added directories; the dump shows exactly that, and the shipped root is documented plugin behavior surfaced at runtime through `agentPresets.roots`. `apps/cli` ships no `config/` directory and its `files` entry is gone. Any composition that mounts the roster — and any embedder of the package — gets the shipped set by default and turns it off with one config line; embedders wanting bare machinery set `includeShippedRoot: false`. The presets' bare plugin names still resolve through the boot's flat installation fallback, unchanged by the move. diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md new file mode 100644 index 0000000000..c2cc586a7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md @@ -0,0 +1,33 @@ +# Agent Note: The shipped preset root is the plugin's own + +Status: implemented + +[English](2026-08-20-plugin-owned-shipped-preset-root.md) | 中文 + +## 问题 + +`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,dump 显示配置的根目录完好而启动却丢弃了它们。该 overlay 还把行的启动时 `config` 冻结在所有热重载之上,重启前对该行的任何 `cordis.patch.yml` 编辑都不生效。外部报告 discussion #3636 给出了准确根因。 + +在"补丁整体替换 `config`"的语义下,任何"必须在用户层之后存活"的值都需要组合后的强制注入——而评审否决了把这份强制留在启动器里:`apps/cli` 对某一个插件的行 id、config 键与优先级做特判,是组合机器不应携带的耦合。 + +## 决定 + +内置 preset 归插件自有。四套内置组合从 `apps/cli/config/agent-presets/` 搬入 `packages/preset/agent-presets/presets/`,列入包的 `files`;`dsh-agent-presets` 相对自己的模块解析 `SHIPPED_PRESET_ROOT`——Loader 在运行时按包名导入插件,目录在源码与安装两种布局中都真实存在于磁盘上,与 `cordis` preset 目录内随行携带 skill 依赖的是同一机制。`resolvedRoots` 变为:除非 `includeShippedRoot` 为 false,先是内置根(`system` 信任),再按序 `config.roots`,最后除非 `includeUserRoot` 为 false 追加推导的可写 home 根——前置,因此内置集合始终挂载并赢得重复 id。 + +这补全了 #2278 为可写根开启的[会话级 preset roster](../architecture/2026-08-03-per-session-agent-presets.zh.md) 方向:两个非配置根现在都属于本包,启动器不带任何插件知识地组合补丁层,压掉、重载冻结与 dump 分叉从"被修复"变为"不再可能发生"。"一定加载"的保证不再依赖补丁顺序:`includeShippedRoot` 在 schema 中默认 true,用户层整体替换该行 `config` 后内置集合依然保留,只有显式 `false`——与整行 disable 同级的故意行为——才会去掉它。组合绑定的是宿主的 agent-plane 服务而非 Web 表面:没有任何 preset 行引用 client 或 web 插件;宿主缺少被注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。 + +## 测试 + +`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。 + +## 曾考虑的替代方案 + +**保留启动器补丁但按组合派生、前置而非替换。** 本修复未曾合入的第一版:对压掉、重载冻结与 dump(曾以带标签层渲染派生补丁)判断均正确,核心即报告者的 overlay 前置形状。在评审中被替代,因为每个变体都让 `apps/cli` 对 roster 行做特判;被否决的是耦合而非机制。 + +**由 bundle 自己声明内置根(`!!js` 包相对路径)。** 去掉启动器耦合,但把"一定加载"的保证重新挂回补丁顺序:用户层整体替换该行 `config` 时 bundle 的条目被丢弃——又回到所报 bug 的形状。 + +**带外提供根目录(启动器提供的上下文值,由插件前置)。** 启动器仍需知道"要为 preset 提供一个事实";特判换了通道继续存在。 + +## 后果 + +`config.roots` 纯粹是部署追加的目录;dump 展示的正是它,内置根成为文档化的插件行为,运行时经 `agentPresets.roots` 呈现。`apps/cli` 不再携带 `config/` 目录,其 `files` 条目移除。任何挂载 roster 的组合——以及任何嵌入本包的使用方——默认获得内置集合,一行配置即可关闭;只要纯机制的嵌入方设 `includeShippedRoot: false`。preset 里的裸插件名仍经启动的扁平安装后备解析,搬移不改变这一点。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml similarity index 56% rename from .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml index 21b6957cd2..c4f148394e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md -2026-08-11-preset-card-description-clamp.md: 16ebf371d5af7c9e54fcc37819696b380856d5cb -2026-08-11-preset-card-description-clamp.zh.md: 5b7a18f41e4b3e8acd681a001f6826b19ca7026d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md +2026-08-21-deepseek-files-inline-fallback.md: 7442089038e2cf47f37661c0f098054d03f67aef +2026-08-21-deepseek-files-inline-fallback.zh.md: 0534514f9bc52f7871f43c288466643cebc7d69c diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md new file mode 100644 index 0000000000..7442089038 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.md @@ -0,0 +1,37 @@ +# Agent Note: Recover DeepSeek image requests from Files resolution failures + +Status: implemented + +English | [中文](2026-08-21-deepseek-files-inline-fallback.zh.md) + +## Problem + +The direct DeepSeek vision route uses provider file ids so repeated requests do not resend image bytes. An unavailable, unsupported, or stalled Files endpoint can prevent chat before the model request begins even though the same endpoint still accepts inline image data. A fallback that retains the 128MiB Files budget would exceed the inline request-body limit, while a fallback that independently transforms images could send different pixels from the failed file-id attempt. + +## Decision + +Files remains the preferred transport. Each request-image file resolution has the configurable `filesApiTimeoutMs` deadline, one minute by default. The stream idle deadline defaults to five minutes, so the Files deadline normally leaves time for inline fallback. A deployment may configure the stream idle deadline to expire first. Successful resolutions refresh the outer idle watchdog. Caller cancellation and the outer stream deadline remain terminal outcomes. + +A file resolution failure discards the transient file parts assembled for that chat attempt and rebuilds the complete image request with base64 data URLs. Every retained image uses the already prepared deterministic `RequestImageAttachment`; the fallback performs no additional decode, resize, or encode, and a chat request never mixes file ids with inline images. Upload mappings committed before a later image fails remain available to later requests. The next request tries Files again, so recovery requires no process-wide outage state. + +Inline fallback has a separate base64-expanded high watermark, `maxInlineRequestImageBytes`, of 20MiB by default. `inlineImageOffloadByteQuantum` defaults to 10MiB, so crossing the high watermark advances the deterministic oldest-image prefix to the next 10MiB removal boundary. The existing 600-image bound and count quantum still apply. File mode retains its 128MiB high watermark and 64MiB removal quantum. + +Provider chat errors keep their existing classifications. A stale file id is invalidated, re-uploaded, and retried once. If that replacement resolution fails, the permitted retry uses the inline representation. A generic chat failure does not switch transports because it does not establish that Files resolution failed. + +## Alternatives considered + +**Send inline images first.** Rejected because successful Files uploads allow deterministic request bytes to be reused across turns without repeating base64 in every request. + +**Mix resolved file ids with inline images after one upload fails.** Rejected because the request would still depend on the failing Files service and would have two independent image budgets. + +**Apply the 128MiB Files bound to inline fallback.** Rejected because base64 expands the payload and can exceed the chat request-body limit. The 20MiB budget leaves space for JSON, text history, and tools. + +**Remember an outage and bypass Files on later requests.** Rejected because a process-local circuit state introduces recovery timing and shared failure state. Retrying Files on the next request detects service recovery without another timer. + +## Verification + +Serializer tests cover file and data-URL representations over the same request versions, all supported media types, tool-result placement, and 20-to-10 base64 offload. Adapter tests cover immediate resolution failure, failure after a partial set of file ids, deadline-triggered fallback, stale-id replacement failure, all-inline request bodies, caller cancellation without fallback, and generic chat failure without a transport switch. Configuration tests cover both inline bounds and independent Files and stream idle deadlines. + +## Consequences + +A Files outage no longer prevents an image chat that fits the inline budget. Fallback repeats image bytes and may omit more history than file mode because its limit is lower. A request can leave successful uploads behind when a later image fails, but their indexed mappings are reusable and do not change the chat body sent by the fallback. Explicit file-management operations continue to expose their own failures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md new file mode 100644 index 0000000000..0534514f9b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md @@ -0,0 +1,37 @@ +# Agent Note: DeepSeek Files 解析失败时恢复图片请求 + +Status: implemented + +[English](2026-08-21-deepseek-files-inline-fallback.md) | 中文 + +## Problem + +DeepSeek 官方视觉路由使用提供方文件 ID,使重复请求不必再次发送图片字节。如果 Files 端点不可用、不受支持或一直不返回,chat 会在模型请求开始前失败,即使同一端点仍接受内联图片数据。沿用 128MiB Files 预算的回退会超过内联请求体上限,独立转换图片的回退则可能发送与失败 file ID 尝试不同的像素。 + +## Decision + +Files 仍是首选传输方式。每张请求图片的文件解析都有可配置的 `filesApiTimeoutMs` 时限,默认一分钟。stream idle 时限默认为五分钟,因此 Files 时限通常会为内联回退留出时间。部署也可以把 stream idle 时限设得更短,让它先终止请求。每次成功解析都会刷新外层 idle watchdog。调用方取消和外层流时限仍直接终止请求。 + +文件解析失败后,适配器会丢弃为该次 chat 尝试组装的临时文件块,并用 base64 data URL 重新组装完整图片请求。每张保留图片都复用已经准备好的确定性 `RequestImageAttachment`;回退不会再次解码、缩放或编码,同一个 chat 请求也不会混用 file ID 和内联图片。较早图片在后续图片失败前已经提交的上传映射会保留,供之后请求使用。下一次请求会重新尝试 Files,因此不需要保存进程级故障状态。 + +内联回退使用独立的 base64 膨胀后高水位,`maxInlineRequestImageBytes` 默认为 20MiB。`inlineImageOffloadByteQuantum` 默认为 10MiB,因此越过高水位时,确定性的最旧图片前缀会推进到下一个 10MiB 移除边界。现有 600 张图片上限和数量步长继续生效。文件模式继续使用 128MiB 高水位和 64MiB 移除步长。 + +提供方 chat 错误继续使用现有分类。失效 file ID 会被清除、重新上传并重试一次。如果替换解析失败,这次允许的重试会使用内联表示。普通 chat 错误不能证明 Files 解析失败,因此不会切换传输方式。 + +## Alternatives considered + +**优先发送内联图片。** 不采用,因为 Files 上传成功后可以跨轮次复用确定性的请求字节,不必在每次请求中重复 base64。 + +**某次上传失败后混用已解析 file ID 和内联图片。** 不采用,因为请求仍依赖发生故障的 Files 服务,而且需要同时处理两套图片预算。 + +**把 128MiB Files 上限用于内联回退。** 不采用,因为 base64 会扩大负载,并可能超过 chat 请求体上限。20MiB 预算会为 JSON、文本历史和工具留下空间。 + +**记住故障,并在后续请求中跳过 Files。** 不采用,因为进程级状态会引入恢复时间和共享故障状态。下一次请求重新尝试 Files,可以在无需新增计时器的情况下发现服务恢复。 + +## Verification + +序列化测试覆盖相同请求版本的文件和 data URL 表示、全部支持的媒体类型、工具结果位置,以及 20MiB 到 10MiB 的 base64 offload。适配器测试覆盖立即解析失败、部分 file ID 成功后的失败、时限触发的回退、失效 ID 替换失败、全内联请求体、调用方取消时不回退,以及普通 chat 错误不切换传输方式。配置测试覆盖两项内联预算,以及相互独立的 Files 和 stream idle 时限。 + +## Consequences + +符合内联预算的图片 chat 不会再因 Files 故障而失败。回退会重复发送图片字节,而且由于上限更低,可能比文件模式省略更多历史。后续图片失败时,请求可能留下较早图片的成功上传,但这些索引映射可以复用,也不会改变回退发送的 chat 请求体。显式文件管理操作继续暴露自身错误。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.i18n.yaml similarity index 57% rename from .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.i18n.yaml index 842a44a9cc..bf02c9b7dd 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md -2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d -2026-08-11-cmdline-program-action.zh.md: 1eb9746162f3b4eb8c43ca71f5abc3213d0203da +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.md +2026-08-23-win32-utf16-nul-truncation.md: 3962730c66d72b9927e5ce6dabde0f50101c5787 +2026-08-23-win32-utf16-nul-truncation.zh.md: 23df80053b23c5e2fbb810c95668bbeef7c91fe9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.md b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.md new file mode 100644 index 0000000000..3962730c66 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.md @@ -0,0 +1,29 @@ +# Agent Note: Win32 folder-picker paths stop truncating at U+XX00 code units + +Status: implemented + +English | [中文](2026-08-23-win32-utf16-nul-truncation.zh.md) + +## Problem + +`readUtf16` in `packages/host/directory-picker-native/src/win32-dialog-bindings.ts` translated the `IFileOpenDialog` result buffer by scanning for a zero byte with `bytes[end] !== 0`. UTF-16LE encodes NUL as two zero bytes, so any BMP code unit whose low byte is zero — U+XX00, such as 开 (U+5F00) — ended the scan early. Selecting a folder like `C:\Users\XIAOPAN\Desktop\安卓开发` returned `C:\Users\XIAOPAN\Desktop\安卓`, and the workspace-creation call failed with `workspace-invalid-path ... ENOENT`. + +## Decision + +The scan ends only when both bytes of a code unit are zero, still advancing two bytes at a time over the same 32KiB `koffi.view` buffer. A regression test drives `readUtf16` through the existing fake koffi COM world with a path containing 安卓开发 (U+5F00), so the termination rule is proven without a real Windows host. + +The fix is adopted verbatim from the community patch series on the `fix/win32-utf16-nul-truncation` branch of the ericcaiwx-star fork — [c8aac14703](https://github.com/ericcaiwx-star/deepseek-harness/commit/c8aac14703a517b8db1573f9ca4ed94dc58e276b) for the scan fix and [e1d6265cb9](https://github.com/ericcaiwx-star/deepseek-harness/commit/e1d6265cb930a0a74cba03c40e73ed872a83575f) for the fixture cleanup — reported in [discussion #580](https://github.com/deepseek-ai/deepseek-harness/discussions/580) (earlier reported in [discussion #563](https://github.com/deepseek-ai/deepseek-harness/discussions/563)). Both cherry-picks retain the original author, ericcaiwx-star; the upstream fork is the source of record for the patch. + +## Alternatives considered + +**Reject the community patch and rewrite the scan locally.** Rejected: the patch is minimal, fits the dialog's existing test approach, and a byte-identical cherry-pick preserves provenance and credit. + +**Decode the whole buffer with `toString('utf16le')` and split at `\0`.** Rejected: it copies the entire buffer instead of scanning, and the split would still depend on the same two-zero-byte rule. + +**Ask COM or koffi for a string length.** Rejected: the binding surface provides no length; the double-zero scan is the standard UTF-16LE NUL test. + +## Consequences + +- Any path containing a U+XX00 code unit survives the picker translation; paths with such characters (for example Chinese folder names) can be selected and used to create workspaces. +- The fix changes no ABI usage, buffer size, or dialog flow; the COM child-process architecture in the [Win32 folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md) is untouched. +- Real-dialog rendering and selection remain a manual Windows check; this change's regression test exercises only the byte-to-string translation against the fake COM world. The fixture path is synthetic (`C:\fixture\安卓开发`) so no real user path appears in the repository. diff --git a/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.zh.md new file mode 100644 index 0000000000..23df80053b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-23-win32-utf16-nul-truncation.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Win32 目录选择器路径不再在 U+XX00 码元处截断 + +Status: implemented + +[English](2026-08-23-win32-utf16-nul-truncation.md) | 中文 + +## 问题 + +`packages/host/directory-picker-native/src/win32-dialog-bindings.ts` 的 `readUtf16` 用 `bytes[end] !== 0` 扫描 `IFileOpenDialog` 结果缓冲区来寻找零字节。UTF-16LE 真正的 NUL 是两个零字节,因此任何低字节为 0 的 BMP 码元——U+XX00,例如「开」(U+5F00)——都会提前结束扫描。选择 `C:\Users\XIAOPAN\Desktop\安卓开发` 这类目录会得到 `C:\Users\XIAOPAN\Desktop\安卓`,随后创建工作区的调用以 `workspace-invalid-path ... ENOENT` 失败。 + +## 决策 + +扫描只有在一个码元的两个字节都为零时才结束,仍按每次两个字节在同一个 32KiB `koffi.view` 缓冲区上推进。回归测试通过既有的假 koffi COM 世界驱动 `readUtf16`,路径包含「安卓开发」(U+5F00),从而不依赖真实 Windows 主机验证终止规则。 + +修复逐字采用 ericcaiwx-star fork 的 `fix/win32-utf16-nul-truncation` 分支上的社区补丁系列——[c8aac14703](https://github.com/ericcaiwx-star/deepseek-harness/commit/c8aac14703a517b8db1573f9ca4ed94dc58e276b) 是扫描修复,[e1d6265cb9](https://github.com/ericcaiwx-star/deepseek-harness/commit/e1d6265cb930a0a74cba03c40e73ed872a83575f) 是 fixture 清理——在 [discussion #580](https://github.com/deepseek-ai/deepseek-harness/discussions/580) 报告(更早在 [discussion #563](https://github.com/deepseek-ai/deepseek-harness/discussions/563) 报告)。两次 cherry-pick 均保留原作者 ericcaiwx-star;上游 fork 是补丁的记录来源。 + +## 考虑过的替代方案 + +**拒绝社区补丁,本地重写扫描。** 拒绝:补丁极小,与目录选择器现有测试方式一致;逐字节一致的 cherry-pick 保留来源与署名。 + +**用 `toString('utf16le')` 解码整个缓冲区再按 `\0` 切分。** 拒绝:复制整个缓冲区而非扫描,且切分仍依赖同一「双零字节」规则。 + +**向 COM 或 koffi 索取字符串长度。** 拒绝:绑定面不提供长度;双零扫描是标准的 UTF-16LE NUL 判定。 + +## 后果 + +- 任何含 U+XX00 码元的路径组件都能通过选择器转译;含这类字符的路径(例如中文目录名)可以选中并用于创建工作区。 +- 修复不改变 ABI 用法、缓冲区大小或对话框流程;[Win32 目录选择器 note](../feature/2026-08-02-win32-in-process-folder-dialog.zh.md) 中的 COM 子进程架构不受影响。 +- 真实对话框渲染与选择仍是手动 Windows 检查;本次回归测试只针对假 COM 世界中的字节到字符串转译。fixture 路径为合成路径(`C:\fixture\安卓开发`),仓库中不出现真实用户路径。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 7b22ab0b47..46d8b1a8ef 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.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/feature/2026-06-14-acp-multi-session.md -2026-06-14-acp-multi-session.md: 7efdf968d1dc71e727c21b0c0ce4a6a51f3e6b42 -2026-06-14-acp-multi-session.zh.md: b713d5bd756d739a8876c7720042a34e81c25209 +2026-06-14-acp-multi-session.md: 540bc0ee798b8e578fd8da293f4319285ec27beb +2026-06-14-acp-multi-session.zh.md: 814f28bb677169d1d65b2c954c28c21c2e2851fb diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md index 7efdf968d1..540bc0ee79 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md @@ -12,7 +12,7 @@ An ACP automation client can keep several conversations alive over one agent sub ## Decision -The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent, exact disposer, and optional in-flight prompt with the durable turn number that eventually settles it. The session header owns its cwd; the bridge keeps no parallel workspace or client-capability state. +The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the module only when it owns the exact agent object, so a foreign same-id object cannot claim the session. The module owns its Agent handle, MCP mounts, model selection, ordered updates, memoized close, and optional in-flight prompt with the durable turn number that eventually settles it. The session header owns its cwd; the bridge keeps no parallel workspace or client-capability state. Every `session/event` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt captures its own user-sourced message `turn/start` and settles only on the matching `turn/end`; injection turns, autonomous plugin or goal turns, and a late end from a cancelled prior turn cannot resolve it. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path. @@ -20,13 +20,13 @@ Permission ownership uses the same exact-agent check against the forward map. Th Background bash tasks carry an opaque owner token equal to the owning session id. `job_output` and `job_kill` compare the caller's token with the executor's job ownership before reading or killing; a predictable job id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it. -Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal. +Per-session close, connection teardown, and plugin disposal share each `AcpSession`'s memoized close. The bridge retains the live map while events and updates drain, settles each pending prompt as cancelled, drains continuable descendants, flushes the session while attached, and disposes Agent scopes in parallel. Exact records leave the map only after their close operation settles. ## Protocol and workspace scope [ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session. -A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). The automation bridge advertises no multi-root capability and rejects non-empty `additionalDirectories`; each fresh session has exactly one workspace, as recorded in the [package contract](../../../../packages/acp/acp/README.md#protocol-contract). +A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). The automation bridge advertises no multi-root capability and rejects non-empty `additionalDirectories`; each session has exactly one workspace, as recorded in the [package contract](../../../../packages/acp/acp/README.md#standard-acp-v1-surface). [The standard transport is one agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path. @@ -42,7 +42,7 @@ A multi-root project inside one session is a separate optional capability: ACP d N sessions can return committed answers, prompt, request permission, and run background jobs concurrently without interleaving or cross-settling. A cancel in one session does not affect its neighbors. The bridge pays for explicit maps and isolation tests, but it does not add one listener set per session and therefore avoids listener fan-out during long-lived connections. -The bridge exposes no protocol method to close one live session independently. Records leave together on connection teardown; navigation and resume belong to host APIs rather than this automation protocol. +Standard `session/close` independently quiesces one live session and leaves its durable state resumable. `session/list` omits active records, and `session/resume` rejects an id that is still live, so an automation client cannot create two Agent objects for one durable session. ## Verification diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md index b713d5bd75..814f28bb67 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -ACP 桥接层将活跃会话存储在 `Map` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent、精确的释放器,以及可选的进行中提示词和最终结算它的持久轮次号。会话 header 拥有其 cwd;桥接层不保留平行的工作区或客户端能力状态。 +ACP 桥接层将活跃会话存储在 `Map` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该模块拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。模块拥有 Agent handle、MCP 挂载、模型选择、有序更新、记忆化关闭,以及可选的进行中提示词和最终结算它的持久轮次号。会话 header 拥有其 cwd;桥接层不保留平行的工作区或客户端能力状态。 每个 `session/event` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的提示词。提示词捕获自己源自用户消息的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;注入轮次、插件或 goal 的自主轮次,以及来自已取消的前一轮次的迟到 end 都不能 resolve 它。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 @@ -20,13 +20,13 @@ ACP 桥接层将活跃会话存储在 `Map` 中。agen 后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`job_output` 和 `job_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 job id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。 -连接拆除时清空活跃 map,将每个待处理的提示词以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 +逐会话关闭、连接拆除和插件释放共享每个 `AcpSession` 的记忆化关闭。桥接层在事件和更新 drain 期间保留活跃 map,把每个待处理提示词结算为已取消,drain 可继续后代,在会话仍挂载时 flush,并行释放 Agent scope。确切记录只在关闭操作结算后离开 map。 ## 协议与工作区作用域 [ACP v1 明确允许一个连接上存在多个并发会话](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24),每个新会话都携带自己的主 `cwd`。本桥实现该会话级多路复用,其中包括[按会话 cwd 决策](../architecture/2026-07-02-fs-per-session-cwd.zh.md)所记录的不同主工作区;它不会为每个会话创建一个 agent 子进程。 -一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。自动化桥接层不公布任何多根能力,并拒绝非空的 `additionalDirectories`;如[包约定](../../../../packages/acp/acp/README.zh.md#protocol-contract)所记录,每个全新会话恰好有一个工作区。 +一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。自动化桥接层不公布任何多根能力,并拒绝非空的 `additionalDirectories`;如[包约定](../../../../packages/acp/acp/README.zh.md#standard-acp-v1-surface)所记录,每个会话恰好有一个工作区。 [标准传输是每个 stdio 连接一个 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。 @@ -42,7 +42,7 @@ ACP 桥接层将活跃会话存储在 `Map` 中。agen N 个会话可以并发地返回已提交的回答、提交提示词、请求权限和运行后台任务,而不会交错或跨会话结算。一个会话中的取消不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 -桥接层不暴露独立关闭单个活跃会话的协议方法。所有记录会在连接拆除时一并移除;会话导航与恢复属于 host API,而非这个自动化协议。 +标准 `session/close` 会独立停稳一个活跃会话,并保留其可恢复持久状态。`session/list` 省略活动记录,`session/resume` 拒绝仍存活的 id,因此自动化客户端不能为同一持久会话创建两个 Agent 对象。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index ac212df09f..1242784a2c 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.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/feature/2026-06-17-filesystem-tool-schemas.md -2026-06-17-filesystem-tool-schemas.md: 0245fd1d8c8771ba73973a1c298cfbbf520ca47a -2026-06-17-filesystem-tool-schemas.zh.md: 62b1928d9ea9e309f6cce71a2153b0f61036da17 +2026-06-17-filesystem-tool-schemas.md: 7680b1200314b5cfe89f8243e2690c748d8c00fe +2026-06-17-filesystem-tool-schemas.zh.md: d75c3f83d7be6dde2c9b70a40411433c581ce830 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 0245fd1d8c..7680b12003 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -107,6 +107,6 @@ Schema tests pin the required/optional argument set per tool, empty-`old_string` **The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the implementation focused, but users may ask for those quickly. They arrive as separate Agent Notes or focused follow-ups rather than overloads of the initial schema. -**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-observation-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. +**No explicit model-facing stale guard.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-observation-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields. **Naming becomes public API.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This Agent Note chooses snake_case up front and treats it as the stable model-facing contract. diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index 62b1928d9e..d75c3f83d7 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -107,6 +107,6 @@ schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒 **首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。这些功能将通过独立 Agent Note 或聚焦的后续工作引入,而不是让初始 schema 承载过多内容。 -**v1 中没有显式的面向模型的陈旧版本防护。** schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-observation-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码暴露,而非模型提供的版本字段。 +**没有显式的面向模型的陈旧版本防护。**schema 不要求模型提供 expected hash/version。这是有意为之:陈旧检查来自后端产生的版本和 `dsh-fs-observation-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码暴露,而非模型提供的版本字段。 **命名成为公开 API。** 一旦发布,将 `file_path` 改为 `filePath` 或 `old_string` 改为 `oldString` 会导致提示词、示例和下游客户端随之改动。本 Agent Note 预先选择 snake_case,并将其视为稳定的面向模型的约定。 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index 412d0e5a41..3680bd3b8a 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.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/feature/2026-06-29-todo-write-tool.md -2026-06-29-todo-write-tool.md: 74af8a66ea86a474533b9c53d6431f7e0026192c -2026-06-29-todo-write-tool.zh.md: 223268ee1e9d330df3299651a594b7b2ed08e1ce +2026-06-29-todo-write-tool.md: 4fe6cd5ce921e0f6fe71fd2548a0cfa6b8d2173b +2026-06-29-todo-write-tool.zh.md: eaeb70b02cdcbdee0ad83d24208e9c8bb0ec3bda diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index 74af8a66ea..4fe6cd5ce9 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc ## Decision -Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. +Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a `todo/write` `SessionEventMap` variant owned by the todo package ([event ownership](../architecture/2026-07-20-todo-event-ownership.md)). Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation. ### Whole-list replace, three-state status diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 223268ee1e..eaeb70b02c 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ## 决策 -新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.zh.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)有意省略 todo 展示。 +新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为由 todo 包拥有的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上(见[事件所有权](../architecture/2026-07-20-todo-event-ownership.zh.md))。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.zh.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)有意省略 todo 展示。 ### 整列表替换,三态 status diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml index dd7b9c49e5..d121a5db15 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.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/feature/2026-06-30-session-store-fork-api.md -2026-06-30-session-store-fork-api.md: 01ff631bc449cf481d89e860934f3f7b94486062 -2026-06-30-session-store-fork-api.zh.md: f0fd158a51f59400756b2724332e7fb115e5e033 +2026-06-30-session-store-fork-api.md: ff7617f4bb306926a7b0782751ae82f6ef0c6371 +2026-06-30-session-store-fork-api.zh.md: ecef7ba2985321677b29d3fc7692a8dcb7afb2b6 diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md index 01ff631bc4..ff7617f4bb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md @@ -46,4 +46,4 @@ The Host creates the child through the agent registry with the selected seed and The public API stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header. -The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree. +This decision excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md); focused store, Host, carrier, and client tests pin the boundary and reconciliation contracts, while the real Chromium scenario pins the assembled message action and lineage tree. diff --git a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md index f0fd158a51..ecef7ba298 100644 --- a/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md @@ -46,4 +46,4 @@ Host 通过 agent(智能体)注册表,以选定的种子和谱系创建子 公开 API 保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话创建时便带有种子事件,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession`/`seedLength`。 -v1 范围仍然排除 ACP(Agent Client Protocol) `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才声明支持该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.zh.md) 覆盖;store、Host、载体与客户端的专项测试固定边界和对账约定,真实 Chromium 场景则固定组装后的消息操作与谱系树。 +本决策排除 ACP(Agent Client Protocol)`session/fork`、对未加载的已持久化会话执行 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才声明支持该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.zh.md)覆盖;store、Host、载体与客户端的专项测试固定边界和对账约定,真实 Chromium 场景则固定组装后的消息操作与谱系树。 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index d9fe00803c..70f4e6e6dc 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.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/feature/2026-07-05-dynamic-workflows.md -2026-07-05-dynamic-workflows.md: eff37365534cd41e46c98e20a5e763a233330647 -2026-07-05-dynamic-workflows.zh.md: 927d194d3432824c6e3026826e07993ce27fce8b +2026-07-05-dynamic-workflows.md: c1bce5d902c77aa1958905f4c31047524ea4b2b3 +2026-07-05-dynamic-workflows.zh.md: 035468a2f4c6dbb0a584a68ba3944681618d9d3b diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index eff3736553..c1bce5d902 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -57,7 +57,7 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea ## Deferred (documented non-goals) - **Background collection** (start tool → run id → completion notice → collect), designed alongside shell/subagent background unification. -- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — implementing it reintroduces CC's determinism bans as a script-contract tightening (scripts may read the clock today). +- **Journaling + resume** (`resumeFromRunId`, cached agent() prefixes) — implementing it reintroduces CC's determinism bans as a script-contract tightening (scripts may read the clock). - **Saved/bundled workflows** (a `.deepseek/workflows/` registry, slash-command API) and **script persistence to a run directory** (the tool-call event already records the script durably). - **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). - **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here. diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 927d194d34..035468a2f4 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -57,7 +57,7 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 ## 延迟(明确的非目标) - **后台收集**(启动工具 → run id → 完成通知 → 收集),与 shell/subagent 后台统一一起设计。 -- **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本约定收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。 +- **日志化 + 恢复**(`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本约定收紧的形式重新引入 CC 的确定性禁令(脚本可以读取时钟)。 - **保存/打包的工作流**(`.deepseek/workflows/` 注册表、斜杠命令 API)和**脚本持久化到运行目录**(工具调用事件已经持久记录了脚本)。 - **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都会明确拒绝,并在消息中注明其已延迟实现)。 - **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index 4b4a5e85c6..ce361aadbd 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.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/feature/2026-07-05-skill-system.md -2026-07-05-skill-system.md: 481cdecbb4acaf5a0c38106ab47c6c489aca507c -2026-07-05-skill-system.zh.md: a0d7dd953337b0e9e764738d6d4cd7ef825999f5 +2026-07-05-skill-system.md: 650ecc0a943d3fd5419481f13d14e5a3a46e94f2 +2026-07-05-skill-system.zh.md: d3330060efe5c9b15752206405efcd562fa2691b diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 481cdecbb4..650ecc0a94 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -14,7 +14,7 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth `@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-filesystem` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the durable session catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so TUI, headless, and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. -Dedicated packaged providers can contribute immutable skills without filesystem discovery. The shipped CLI declares `@deepseek-ai/dsh-skill-badge` disabled by default; enabling its composition row contributes the official badge instructions through the same registry and consumer ([decision](2026-08-06-bundled-dsh-badge-skill.md)). +Dedicated packaged providers can contribute immutable skills without filesystem discovery. The shipped CLI declares `@deepseek-ai/dsh-skill-badge` disabled by default; enabling its composition row contributes the official badge instructions through the same registry and consumer (see [the package contract](../../../../packages/skill/skill-badge/README.md)). Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session catalog. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index a0d7dd9533..d3330060ef 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -14,7 +14,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 `@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-filesystem` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责持久化会话目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 TUI、headless 与 ACP(Agent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry`、`local` 和 `tool` 分支分别转发给对应的所有者。 -专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[决策](2026-08-06-bundled-dsh-badge-skill.zh.md))。 +专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[包约定](../../../../packages/skill/skill-badge/README.zh.md))。 提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index bb470cfea4..c07962d3a5 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.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/feature/2026-07-06-approval-seam.md -2026-07-06-approval-seam.md: ace41ebbb94cc24c2fdd3e7ae2d3a69b28b169af -2026-07-06-approval-seam.zh.md: 63b7478b0903c149fc87fd944c95e94cc8de2359 +2026-07-06-approval-seam.md: d7f8d90d408bb85c20f6a4dd0373de1aff9b180b +2026-07-06-approval-seam.zh.md: 8f2481ff0e25193e91118d5b8181cb4dd3cf40e5 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index ace41ebbb9..d7f8d90d40 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws. +The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP profile app (`@deepseek-ai/dsh-acp-app`, as in [the acp-agent example](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws. What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 63b7478b09..8f2481ff0e 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -25,7 +25,7 @@ Status: implemented # policy: never # deployment default for sessions without an override; 'ask' when omitted ``` -仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。 +仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP profile 应用(`@deepseek-ai/dsh-acp-app`,如 [acp-agent 示例](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。 组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久化的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index ee6ca0ce0d..cf36bebebf 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 7c451d8fb2d59c20ad8170e8c74a93fad911573a -2026-07-06-sandbox.zh.md: 6fa1659039f916732afd3b5f24831bd062f11498 +2026-07-06-sandbox.md: 660406167b757a0c7161184dd98e4ab6327f12f8 +2026-07-06-sandbox.zh.md: e4fcb37a4cb56b7aa90eebe365c3ec5ddc0a9ed1 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 7c451d8fb2..660406167b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -14,7 +14,7 @@ Confinement alone leaves two gaps. A denial with no escalation path is terminal ## Decision -One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement and per-session workspace roots landed as follow-ups on the same policy carrier; the remaining phases — the `subagent-acp` consumer, more environments, and a Windows chain — stay under § Deferred phases. +One seam, one per-platform chain of local backends, one consumer, and two levers on top: a per-call escalation path and per-session runtime modes. Everything below composes from the leaf `cordis.yml`; nothing touches `agent-loop`. Cross-family fs enforcement, per-session workspace roots, and the Windows chain are shipped follow-ups on the same policy carrier; the `subagent-acp` consumer and more environments remain under § Deferred phases. ### How a deployment uses it @@ -128,7 +128,6 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from the main repository under `native/` following the `@deepseek-ai/node-addon-landlock-run` template, plus its profile dialect, denial signatures, and runner-failure rules. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered @@ -186,7 +185,7 @@ Costs and accepted limits: - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background job stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result. -- **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). +- **What happens on a platform with no backend?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE`, and the command never spawns. - **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime. - **Does the sandbox restrict network or process visibility?** `SandboxMode` claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host `/proc/` magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged ([decision](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)). Whether network restriction becomes its own knob is left open in § The seam. - **Which tools actually run confined?** OS subprocesses through `ctx.shell` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6fa1659039..e4fcb37a4c 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -14,7 +14,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 ## 决策 -一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 +一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制、按会话工作区根目录与 Windows 链已经作为后续设计落到同一策略载体上;`subagent-acp` 消费方与更多环境仍列在 § 延迟阶段。 ### 部署方式 @@ -128,7 +128,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 @@ -186,7 +185,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 -- **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 +- **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 - **沙箱限制网络或进程可见性吗?** `SandboxMode` 只声称文件影响,并且没有后端声称限制网络。进程可见性因后端而异:bwrap 会取消共享 PID 命名空间,并挂载与其匹配的 procfs,因为宿主 `/proc/` 的魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变(见[相关决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。 - **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 4eb1d7d4df..5ec6161319 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: f9d997fd06dbf14f86ec344a2d5f16c7412119d0 -2026-07-07-mcp-client-plugin.zh.md: 8cd59e5efe415b75c9d797a569c4865b4a91d750 +2026-07-07-mcp-client-plugin.md: cde9cf1130d6b6b971ddf35c64e34ad6d9fb7dee +2026-07-07-mcp-client-plugin.zh.md: 0cf69a97bc714efcc4083fc2b37519dfc10cdf32 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index f9d997fd06..cde9cf1130 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -83,7 +83,7 @@ The model sees `mcp__github__create_issue`, `mcp__github__search_code`, `mcp__we ### Lifecycle -Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API for now. Public names are pure functions of `(serverName, rawName)`, so an HMR swap that keeps `serverName` recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool. +Boot-time from `cordis.yml`. HMR (`@cordisjs/plugin-hmr`) provides hot-swap: editing the yml entry triggers dispose of the old instance (disconnects, unregisters tools) and creation of a new one (connects, discovers, registers). No runtime-dynamic API is provided. Public names are pure functions of `(serverName, rawName)`, so an HMR swap that keeps `serverName` recreates identical model-facing names — session history and permission rules stay valid — and adding or removing an unrelated server never renames an existing tool. ### Tool discovery and registration @@ -167,7 +167,7 @@ Rejected. There is no foreseeable alternative MCP client implementation — MCP ### Auto-reconnect with exponential backoff -Rejected for v1: it added a partial-availability state (tools registered but temporarily non-functional), and stdio crashes often indicate configuration problems retrying cannot fix; HMR was the recovery path. Operational feedback reversed the deferral — the [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) implements it with a bounded per-outage budget and an opt-out. +Rejected by the connect-once design: it added a partial-availability state (tools registered but temporarily non-functional), and stdio crashes often indicate configuration problems retrying cannot fix; HMR was the recovery path. Operational feedback reversed the deferral — the [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) implements it with a bounded per-outage budget and an opt-out. ### Bridge Resources and Prompts @@ -179,7 +179,7 @@ Rejected — this was the original proposal, built on the premise that "most MCP ### Server-only namespace (`github__create_issue`, no `mcp__` marker) -Rejected for v1. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (`mcp__*`). The marker costs 5 characters; the `mcp____` spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRuntime later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change. +Rejected. It prevents cross-server collisions but does not separate MCP registrations from native harness tools, and it forfeits MCP-wide policy shapes (`mcp__*`). The marker costs 5 characters; the `mcp____` spelling matches Claude Code and Codex, maximizing model familiarity. If the ToolRuntime later grows source-aware namespaces, dropping the literal marker can be revisited as a naming-policy change. ### Deriving the namespace from the server-announced `serverInfo.name` @@ -212,7 +212,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex ## Consequences - A `cordis.yml` entry per MCP server is the entire integration cost: `serverName: filesystem` + a stdio command (or a Streamable HTTP URL) puts `mcp__filesystem__read_file` in the model's tool list, callable, with the raw `read_file` on the wire. -- Public names are part of session history and permission/configuration APIs; the naming algorithm is a v1 contract pinned by tests, and changing it after release is a breaking change. +- Public names are part of session history and permission/configuration APIs; tests pin the naming algorithm, and changing it after release is a breaking change. - The `mcp____` qualifier costs tokens on every name. Accepted: descriptions and JSON schemas dominate tool-definition tokens, and the qualifier buys stable identity, collision isolation, and MCP-wide policy shapes (`mcp__*`, `mcp__github__*`). - **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. - **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 8cd59e5efe..0cf69a97bc 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -83,7 +83,7 @@ type Config = StdioConfig | StreamableHttpConfig ### 生命周期 -启动时从 `cordis.yml` 加载。HMR(热模块替换)(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目触发旧实例的 dispose(资源释放)(断开连接、注销工具),并创建新实例(连接、发现、注册)。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——而添加或移除不相关的服务器永远不会重命名已有工具。 +启动时从 `cordis.yml` 加载。HMR(热模块替换)(`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目触发旧实例的 dispose(资源释放)(断开连接、注销工具),并创建新实例(连接、发现、注册)。不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——而添加或移除不相关的服务器永远不会重命名已有工具。 ### 工具发现与注册 @@ -167,7 +167,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 指数退避自动重连 -v1 否决:引入了部分可用状态(工具已注册但暂时不可用),且 stdio 崩溃往往表明配置问题,重试无法修复;HMR 曾是恢复路径。运营反馈扭转了该延期决定——[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.zh.md) 以有界的单次故障预算和 opt-out 实现了自动重连。 +单次连接设计否决了该方案:它会引入部分可用状态(工具已注册但暂时不可用),且 stdio 崩溃往往表明配置问题,重试无法修复;HMR 曾是恢复路径。运营反馈扭转了该延期决定——[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.zh.md)以有界的单次故障预算和 opt-out 实现了自动重连。 ### 桥接 Resources 和 Prompts @@ -179,7 +179,7 @@ v1 否决:引入了部分可用状态(工具已注册但暂时不可用) ### 仅服务器命名空间(`github__create_issue`,无 `mcp__` 前缀) -v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具分离,也丧失了 MCP 全局策略匹配模式(`mcp__*`)。前缀仅多花 5 个字符;`mcp____` 拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRuntime 未来引入源感知命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。 +不予采纳。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具分离,也丧失了 MCP 全局策略匹配模式(`mcp__*`)。前缀仅多花 5 个字符;`mcp____` 拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRuntime 未来引入源感知命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。 ### 从服务器公告的 `serverInfo.name` 派生命名空间 @@ -212,7 +212,7 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha ## 后果 - 每个 MCP 服务器只需 `cordis.yml` 中的一条配置即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL),就能将 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议上使用原始的 `read_file`。 -- 公开名称是会话历史和权限/配置 API 的一部分;命名算法是由测试固定的 v1 约定,发布后变更即为破坏性变更。 +- 公开名称是会话历史和权限/配置 API 的一部分;测试固定了命名算法,发布后变更即为破坏性变更。 - `mcp____` 限定符在每个名称上消耗 token。已接受:描述和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配模式(`mcp__*`、`mcp__github__*`)。 - **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 05e9444ed9..cf3399f4b4 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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/feature/2026-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: d408bb774fa62dd6ff0deef7003f5b0851b80e82 -2026-07-08-self-referential-cordis-toolset.zh.md: 3c07a0af8c3f3a26795d14fc540db6e0f63775e4 +2026-07-08-self-referential-cordis-toolset.md: feb613d74c6be65f13aa9005b9f0d90c57beb14b +2026-07-08-self-referential-cordis-toolset.zh.md: 8840e548cac6dba2d0b5a9e724b747e46d187097 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index d408bb774f..feb613d74c 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -75,7 +75,7 @@ The correctness investment therefore goes where it pays for every capability at **A hand-maintained service/event reference in the tool.** The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated `api-catalog.ts` because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use. -**A new `cordis/mount` session event.** A durable event recording each mount's source and name has clear precedent (`hook/invoked`, `compaction/start`). It was declined for v1: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a full changed request header, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs the mount source and name outside the tool call. +**A new `cordis/mount` session event.** A durable event recording each mount's source and name has clear precedent (`hook/invoked`, `compaction/start`). Rejected: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a full changed request header, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs the mount source and name outside the tool call. **A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *API* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.shell`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 3c07a0af8c..8840e548ca 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -75,7 +75,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 **在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。 -**新增 `cordis/mount` 会话事件。** 一个持久事件记录每次挂载的源码和名称,有明确先例(`hook/invoked`、`compaction/start`)。v1 中予以否决:挂载和卸载已经作为 `tool/call`/`tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要在工具调用之外取得挂载的源码和名称,日后仍可添加。 +**新增 `cordis/mount` 会话事件。**一个持久事件记录每次挂载的源码和名称,有明确先例(`hook/invoked`、`compaction/start`)。不予采纳:挂载和卸载已经作为 `tool/call`/`tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要在工具调用之外取得挂载的源码和名称,日后仍可添加。 **加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始上下文,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的 *API*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的上下文逃逸,但门面暴露的能力(`ctx.shell`、`ctx.fs`、`ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index f1a04dfe6a..4631210523 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.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/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md -2026-07-12-subagent-persona-tool-filter-and-depth.md: 08dffb459621b0d76c7e08ac6ed9a566fcc6e131 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: e484319f4ea76f4bc3e833e8296142480d374516 +2026-07-12-subagent-persona-tool-filter-and-depth.md: 26349e87c008ff89fa8f7ebfd7fce30e446180d4 +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6628cd84a008c59c991549668c76a2ce118edcf6 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 08dffb4596..26349e87c0 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -53,7 +53,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count. -Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. +Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/python-sdk-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`. A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index e484319f4e..6628cd84a0 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -55,7 +55,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma 有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。 -每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而 ACP 与 headless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 +每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/python-sdk-agent/cordis.yml)采用这项通用策略,而 ACP 与 headless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。 部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。 diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index 5f6c5f7ae4..8a7560709a 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.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/feature/2026-07-14-cross-family-fs-sandbox.md -2026-07-14-cross-family-fs-sandbox.md: bd6284d47c0a8a5a4a58ec50f060eb12e2d9c75b -2026-07-14-cross-family-fs-sandbox.zh.md: a32f23175a67ca87b50124933a369e4086379054 +2026-07-14-cross-family-fs-sandbox.md: fca19cd30a958cb035c69d104dae249e0b70ef82 +2026-07-14-cross-family-fs-sandbox.zh.md: 42b082ff72649d1bea80839c89474f479cfe3af0 diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index bd6284d47c..fca19cd30a 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -69,7 +69,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the - **Keep the override event in `dsh-shell` as `shell/sandbox-mode`** — rejected: the event is policy state consumed by two families; leaving it bash-named forces `dsh-fs-sandbox` to depend on bash vocabulary. Pre-release, the rename is a same-change move with snapshot re-records, no shims. - **Escalation choreography imported from the approval/agent packages into `dsh-sandbox`** — rejected: it would invert the layering (a base vocabulary package depending on UI/agent packages). The structural approver keeps the logic single-sourced in `dsh-sandbox` while the dependencies stay in the tool layer that already holds them. - **A consolidated mutation-options object on the fs seam** (the shape first sketched for the per-call carrier) — rejected on friction: it splits `signal` across an options bag for mutations while reads keep it positional. A trailing optional `SandboxExecutionPolicy` matches bash's carry-and-ignore pattern and keeps `signal` symmetric across the seam. -- **Extra writable-root grants on `SandboxPolicy` now** — deferred unchanged: `writableRoots()` derives from the mode meaning today; ad-hoc grants are an escalation-scope question the sandbox RFC left open. +- **Extra writable-root grants on `SandboxPolicy`** — deferred unchanged: `writableRoots()` derives from the defined mode meaning; ad-hoc grants are an escalation-scope question the sandbox RFC left open. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index a32f23175a..42b082ff72 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -69,7 +69,7 @@ Status: implemented - **把覆盖事件留在 `dsh-shell` 里作 `shell/sandbox-mode`**——否决:该事件是被两个家族消费的策略状态;保留 bash 命名会迫使 `dsh-fs-sandbox` 依赖 bash 词汇。预发布阶段,该改名是同一变更内的迁移,附带快照重录,无任何 shim。 - **把升级编排从 approval/agent 包导入 `dsh-sandbox`**——否决:那会倒置分层(一个基础词汇包依赖 UI/agent 包)。结构式 approver 让逻辑单一来源于 `dsh-sandbox`,而依赖留在本就持有它们的工具层。 - **fs seam 上一个合并的 mutation-options 对象**(per-call 载体最初草拟的形状)——因摩擦被否决:它会把 `signal` 拆进变更专用的选项包,而读取仍保持位置参数。一个末尾可选的 `SandboxExecutionPolicy` 匹配 bash 的携带并忽略模式,并使 `signal` 在整个 seam 上保持对称。 -- **现在就在 `SandboxPolicy` 上加额外的可写根授权**——照旧延后:`writableRoots()` 如今由模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 +- **在 `SandboxPolicy` 上增加额外的可写根授权**——照旧延后:`writableRoots()` 由既定模式含义推导;临时授权是沙箱 RFC 留下的升级作用域问题。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index d2887293fe..6aaf55599a 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.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/feature/2026-07-19-human-goal-command.md -2026-07-19-human-goal-command.md: 7ed104eef1a48879a6e93af707c6ea9d8f97190c -2026-07-19-human-goal-command.zh.md: 523ac0b6956a7ad7a2428e187b2d0f8f99bd1120 +2026-07-19-human-goal-command.md: b4e47aa687d60aa5ea4d30ad08826fbaa3978393 +2026-07-19-human-goal-command.zh.md: fcb34d5acc02d44dcb68a1f3c511cbde495673ea diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 7ed104eef1..b4e47aa687 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -12,7 +12,7 @@ The command must also respect the goal design's two kinds of state. Durable phas ## Decision -`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition, so every command adapter in the composition discovers the same command; an incompatible app omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. +`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one `goal` definition in the Cordis scope where the producer is mounted, so every command adapter reading that agent's scope discovers the same command; an incompatible app or agent preset omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop. The command follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. The commit permalink makes the researched grammar durable even as Codex evolves. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior. @@ -40,11 +40,11 @@ Generic slash input, status text, and errors are not persisted. Successful goal `agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation. -The TUI app bundle makes the opposite product choice. It defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer; `goals: false` removes the stack coherently. The [ACP automation app](../simplification/2026-07-23-acp-automation-only-protocol.md) also defaults the goal domain and model tools but deliberately omits command services. The Python SDK runtime closure ships this producer, commands, and the goal stack so an external `cordis.yml` can compose the same command. +The TUI app bundle makes the opposite product choice. It defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer; `goals: false` removes the stack coherently. The Web bundle keeps the goal domain and driver on the host for remote access, disables the host command producer, and mounts the producer in the `standard`, `code`, and `cordis` agent presets; `minimal` omits both the command and model goal tools. A preset switch does not mutate host-owned goal state, and the Web GoalBar retains direct edit, pause, resume, and clear controls. The [ACP automation app](../simplification/2026-07-23-acp-automation-only-protocol.md) also defaults the goal domain and model tools but deliberately omits command services. The Python SDK runtime closure ships this producer, commands, and the goal stack so an external `cordis.yml` can compose the same command. ## Testing -The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI defaults, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. ACP backend snapshots continue to pin the goal tool schemas independently of this human command. +The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI defaults, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. Web composition tests cover preset-scoped command discovery, Minimal omission, disposal during preset switching, and the assembled browser command list. ACP backend snapshots continue to pin the goal tool schemas independently of this human command. ## Alternatives considered @@ -57,7 +57,7 @@ The producer suite uses the real command registry, goal service, agent registry, ## Consequences -- TUI exposes one Codex-shaped `/goal` command supplied by a removable plugin. +- TUI and non-Web base compositions expose one Codex-shaped `/goal` command supplied by a removable plugin; Web presets expose it only where they mount the producer. - Human status distinguishes durable phase from live activation and reports the exact goal-round cap. - Direct pause, resume, clear, creation, and edit consume no model turn while their accepted mutations remain reconstructable from the session log. - Restored sessions wait for a human decision; `/goal resume` is the literal command path, while an ordinary prompt in any language may authorize the model tool path. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 523ac0b695..fcb34d5acc 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它注册一个全局 `goal` 定义,因此组合中的每个命令适配器都会发现同一个命令;不兼容的应用应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 +位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它在自身挂载的 Cordis scope 中注册一个 `goal` 定义,因此读取目标 agent scope 的每个命令适配器都会发现同一个命令;不兼容的应用或 agent preset 应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。 该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、Round 计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。 @@ -40,11 +40,11 @@ Status: implemented `agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在与调用关联的一个物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。 -TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方;`goals: false` 会一致地移除整个栈。[ACP(Agent Client Protocol)自动化应用](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)也默认挂载目标领域与模型工具,但有意省略命令服务。Python SDK 运行时闭包交付本生产方、命令与目标栈,使外部 `cordis.yml` 能组合相同命令。 +TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方;`goals: false` 会一致地移除整个栈。Web 组合包把 goal 领域与驱动器保留在 host 中以供远程访问,停用 host 命令生产方,并在 `standard`、`code` 与 `cordis` agent preset 中挂载该生产方;`minimal` 会同时省略命令与模型 goal 工具。切换 preset 不会改变 host 所拥有的 goal 状态,Web GoalBar 仍保留直接 edit、pause、resume 与 clear 控制。[ACP(Agent Client Protocol)自动化应用](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)也默认挂载目标领域与模型工具,但有意省略命令服务。Python SDK 运行时闭包交付本生产方、命令与目标栈,使外部 `cordis.yml` 能组合相同命令。 ## 测试 -生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、dispose(资源释放)、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、无目标状态下的所有控制命令、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致停用、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。 +生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、dispose(资源释放)、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、无目标状态下的所有控制命令、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致停用、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。Web 组合测试覆盖 preset scope 中的命令发现、Minimal 省略、切换 preset 时的 dispose,以及组装后浏览器中的命令列表。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。 ## 考虑过的替代方案 @@ -57,7 +57,7 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 ## 后果 -- TUI 暴露由可移除插件提供的 Codex 形态 `/goal` 命令。 +- TUI 与非 Web 基础组合暴露由可移除插件提供的 Codex 形态 `/goal` 命令;Web preset 仅在挂载生产方时暴露该命令。 - 人类状态会区分持久阶段与实时激活态,并报告准确的目标 Round 上限。 - 直接暂停、恢复、清除、创建与编辑不消耗模型轮次,而其已接受变更仍可从会话日志重建。 - 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 736d2d2630..0702b19390 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: fc9dee1d767d2de4cbb5170499a10cf3a231c333 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 30ac1dcff9e6400a3bcf58f7b8e5237e20bd5c04 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 359bb9048632222518d87aadd348bca217c8f7c4 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 83293fbe9b..30ac1dcff9 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -69,7 +69,7 @@ interface ComposerAttachment { This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. -The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission prepares a provider-independent master by applying orientation, removing metadata, converting to 8-bit sRGB/sRGBA, and preserving aspect ratio under independent dimension and byte limits. Reads verify the digest, byte length, and logged metadata. Route-specific deterministic request versions are cached separately; the full policy is recorded in [Unified image masters, request versions, and provider files](2026-08-20-unified-image-request-pipeline.md). The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME shape, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, and decoded-pixel count; it validates every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes. +Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME fields, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, decoded-pixel count, and master preparation. It prepares and verifies every batch member once before publishing any member, so one malformed image cannot create partial references and large images are not decoded and encoded again at commit. Storage commits then run in submission order. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced under the existing storage rule. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes. `session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. @@ -122,15 +122,15 @@ Base64 crosses a wire boundary once and is discarded after persistence. Each fro Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. +The host is the authoritative preflight point. It resolves the session's latest routed provider and model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects a new image prompt before writing an attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial chain ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)), including steering that does not enter the queued UI mirror. This gives a prompt and concurrent selection a deterministic order. Selection itself may target a text-only model after images enter durable history; the shared LLM runtime replaces retained image blocks with deterministic text placeholders for that request. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past admission. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing an attachment or event; its rejection appears through the composer's transient toast. -Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each retained image reference including references nested inside tool results, and emit native image content only for models that declare image input. Both adapters request the same deterministic route-specific version from the durable normalized attachment. Pi-AI carries it inline under a base64-aware request budget. The built-in DeepSeek route advertises `deepseek-v4-flash-vision-exp`, uploads every retained version through Files API, and sends `file_id` blocks with indexed reuse, expiry, bounded stale-id retry, quota cleanup, and explicit deletion. DeepSeek text models, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or silently skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. -Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. +Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route uses the same deterministic request versions as ordinary turns. A text-only route receives the same deterministic attachment placeholders as any other LLM request. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. ### History rendering and original preview @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 3.5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 40 million intrinsic pixels per image, and 2000 pixels on either side. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Source intake defaults are 32 MiB per image, 20 images and 100 MiB aggregate image bytes per message, 100 million decoded pixels per image, and 16384px on either side. The provider-independent master defaults to a 2048px long edge and 4 MiB safety cap. Provider request pixel and encoded-byte limits are separate route policies. These deployment-varying limits are validated backend configuration and enforced before persistence or request transmission. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap, 160 MiB by default, and fails load if it cannot hold the aggregate source limit after base64 and envelope expansion. A body without a declared length is rejected when it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, excess per-side dimensions, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. @@ -148,11 +148,11 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | Surface | Responsibility | | --- | --- | -| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and single/batch admission through `ctx.attachments`. | -| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | -| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | -| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | +| `packages/attachment/attachment` | Opaque attachment and request-version identifiers, image references, policies, failures, batch admission, derived reads, and crops through `ctx.attachments`. | +| `packages/attachment/attachment-local` | Private content-addressed masters, deterministic request cache, complete raster decoding, integrity verification, and configuration. | +| `packages/llm/llm` | Role-neutral `ImageBlock`, input-modality metadata, exact adapter generations, and text-only request projection. | +| `packages/llm/llm-pi-ai` | Resolve durable images to deterministic inline request versions. | +| `packages/llm/llm-deepseek` | Resolve official vision input to deterministic request versions and Files API ids. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, Code Mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -210,11 +210,11 @@ Rejected because tool renderers are pure, synchronous, and replayable. MCP prepa ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. -- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection ordering, text-only queue edits, and text-only request projection. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. -- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. +- Adapter and compaction tests cover deterministic Pi-AI request versions, DeepSeek Files upload and reuse, stale-id recovery, text-only projection, recursively nested tool-result images, shared summary request versions, and explicit image-output rejection. - Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. -- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. +- Credentialed real-API tests cover the configured Anthropic route and the built-in `deepseek-official` Files path. The DeepSeek test does not use a custom provider entry. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index fc9dee1d76..359bb90486 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -69,7 +69,7 @@ interface ComposerAttachment { 这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 -本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。准入会应用方向、删除元数据、转换为 8-bit sRGB/sRGBA,并在独立尺寸和字节上限内保持宽高比,生成与提供方无关的主版本。读取会校验摘要、字节长度和已记录元数据。路由专用的确定性请求版本单独缓存,完整策略见[统一图片主版本、请求版本和提供方文件](2026-08-20-unified-image-request-pipeline.zh.md)。 第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 形状,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数;它会在保存任何成员之前校验每个批次成员,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 +Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 字段,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸、解码像素数和主版本准备。它会在发布任何成员之前只准备并验证每个批次成员一次,因此一张畸形图片不会产生部分引用,大图也不会在提交时重复解码和编码。随后按顺序提交存储。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能按现有存储规则保持无引用状态。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 @@ -122,15 +122,15 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.zh.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 +宿主是权威的前置检查点。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果模型明确排除图片输入,宿主会在写入附件或事件前拒绝新的图片提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一条逐 agent 串行链([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.zh.md)),也包括不进入排队 UI 镜像的 steering。这会为提示词和并发选择提供确定顺序。图片进入持久历史后仍可选择纯文本模型;共享 LLM 运行时会在该请求中把保留的图片块替换为确定的文本占位符。`session.updateQueue` 只接受文本内容,因此队列编辑无法绕过准入注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入附件或事件;拒绝会通过 composer 的短时 toast 显示。 -Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个保留的图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。两个适配器都从持久主版本请求同一个确定性路由版本。Pi-AI 在考虑 base64 扩张的请求预算内内联携带它。内置 DeepSeek 路由公布 `deepseek-v4-flash-vision-exp`,把每个保留的版本上传到 Files API,并通过索引复用、过期处理、有界陈旧 ID 重试、配额清理和显式删除发送 `file_id` 块。DeepSeek 纯文本模型、未声明图片能力的自定义模型和未列出的透传 ID 保持纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。适配器不得展平或静默跳过保留图片;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 -压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 +压缩会把选定的会话前缀和其中的图片引用回放到已配置的摘要生成路径。支持视觉的路径使用与普通轮次相同的确定性请求版本。纯文本路径接收与其他 LLM 请求相同的确定性附件占位符。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 ### 历史渲染与原图预览 @@ -140,7 +140,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 3.5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片 4,000 万个固有像素,以及任一边 2,000 像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。源文件输入默认限制为每张图片 32 MiB、每条消息 20 张图片和 100 MiB 图片总字节数、每张图片一亿解码像素,以及任一边 16384px。与提供方无关的主版本默认长边 2048px,独立安全上限 4 MiB。提供方请求的像素和编码字节上限是单独的路由策略。这些随部署变化的限制属于经过校验的后端配置,并在持久化或请求发送前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限,默认 160 MiB;如果该上限无法容纳源文件总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。未声明长度的请求体在越过上限时即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、超出单边尺寸限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 @@ -148,11 +148,11 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, | 接口 | 职责 | | --- | --- | -| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误,以及通过 `ctx.attachments` 提供的单张/批量准入。 | -| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | -| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | -| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | +| `packages/attachment/attachment` | 不透明附件和请求版本标识符、图片引用、策略、错误,以及通过 `ctx.attachments` 提供的批量准入、派生读取和裁剪。 | +| `packages/attachment/attachment-local` | 私有内容寻址主版本、确定性请求缓存、完整光栅解码、完整性校验和配置。 | +| `packages/llm/llm` | 角色无关的 `ImageBlock`、输入模态元数据、精确适配器代次和纯文本请求投影。 | +| `packages/llm/llm-pi-ai` | 把持久图片解析为确定性内联请求版本。 | +| `packages/llm/llm-deepseek` | 把官方视觉输入解析为确定性请求版本和 Files API ID。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、Code Mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -210,11 +210,11 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 -- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的排序、仅文本的队列编辑,以及纯文本请求投影。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 -- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 适配器与压缩测试覆盖确定性 Pi-AI 请求版本、DeepSeek Files 上传与复用、陈旧 ID 恢复、纯文本投影、递归嵌套在工具结果中的图片、共享摘要请求版本,以及明确拒绝图片输出。 - 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 -- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 +- 需要凭据的实际 API 测试会覆盖配置的 Anthropic 路由和内置 `deepseek-official` Files 路径。DeepSeek 测试不使用自定义提供方条目。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml index a9a33f4ebf..1d9c28595a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.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/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md -2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a -2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a +2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: eda9d3a3de91944a298070d6cc22f632294f7a28 +2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1a72c6b1cdb7453b8468d6e6be37aec76323f714 diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md index 84314eaf58..eda9d3a3de 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md @@ -13,19 +13,19 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [ Three packages, layered exactly like the existing Python stack, plus one Service Provider registration: - **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message). -- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). -- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`. +- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. `RunResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`). +- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key. - **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself. -`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line. +`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); the private `@deepseek-ai/dsh-sdk-python-runtime` carrier consumes the shared protocol through its packaged closure. ## Testing Four tiers, per [testing policy](../../../../docs/testing.md): - **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages. -- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold. -- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side. +- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. +- **Keyless snapshot** — `examples/python-sdk-agent/tests/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through an ordered `llm-replay` patch. Four scenarios — text turn, bash tool, spawn subagent, and the minimal persistent-tool composition — each pin the normalized notification stream, SDK turn result, and persisted parent and child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side. - **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design. ## Alternatives considered @@ -36,7 +36,7 @@ Four tiers, per [testing policy](../../../../docs/testing.md): **Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the genuinely shared provider-side parts moved into the subagent seam's `out-of-process.ts`, and the process mechanics live in the `dsh-subprocess` seam. -**Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears. +**Resolve `dsh` only from `PATH`.** Rejected: a Node consumer does not reliably inherit a project-local `.bin` directory. The same-version package dependency supplies the built CLI for installed consumers and the source entry for a clean checkout. **Export source modules, normalization helpers, and subscription producer operations.** These are implementation details with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish. @@ -44,6 +44,6 @@ Four tiers, per [testing policy](../../../../docs/testing.md): ## Consequences -**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself. +**Bought**: the SDK runtime protocol has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam has a harness-native out-of-process backend whose children are full peers (own config, persistence, tools); the SDK profile has snapshot coverage through the SDK path itself. **Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus. diff --git a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md index a822aac655..1a72c6b1cd 100644 --- a/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md @@ -13,19 +13,19 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ 三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册: - **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。 -- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 -- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。 +- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。干净 checkout 中若不存在 `lib/bin.js`,client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。`RunResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(client 运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。 +- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。 - **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。 -`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。 +`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);私有 `@deepseek-ai/dsh-sdk-python-runtime` 载体通过其打包闭包消费共享协议。 ## 测试 四层,依[测试政策](../../../../docs/testing.zh.md): - **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。 -- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。 -- **免密钥快照**——`examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制 fixture(测试前置数据)(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本轮次、bash 工具、spawn subagent——各自钉住规范化通知流、SDK 轮次结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 +- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。 +- **免密钥快照**——`examples/python-sdk-agent/tests/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时,并通过有序 `llm-replay` patch 回放已录制 fixture(测试前置数据)。文本轮次、bash 工具、spawn subagent 与极简持久工具组合四个场景分别钉住规范化通知流、SDK 轮次结果,以及持久化的父日志与子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。 - **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。 ## 考虑过的替代方案 @@ -36,7 +36,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ **把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但协议(ACP SDK 连接 vs harness JSON-RPC)、子进程约定(任意 ACP agent vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别字段会把两个协议埋进一个包;真正共享的提供方侧部分移入 subagent seam 的 `out-of-process.ts`,进程机制则住在 `dsh-subprocess` seam。 -**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel 包。TypeScript 消费方按定义就有 Node,且仓库内消费方还有工作区;为尚不存在的消费方编造发行方案违反「只实现当前需求」的规则。推迟到真实的 npm 发行消费方出现时再处理。 +**只从 `PATH` 解析 `dsh`。** 拒绝:Node 消费方不一定继承项目本地 `.bin` 目录。同版本包依赖为已安装消费方提供构建后 CLI,并为干净 checkout 提供源码入口。 **导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现细节;暴露它们会让调用方不得不理解客户端如何校验与分发协议输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。 @@ -44,6 +44,6 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[ ## 后果 -**收益**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是 seam Agent Note 所设想的递归组合方式;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。 +**收益**:SDK 运行时协议拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 拥有一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具);SDK profile 通过 SDK 路径本身获得快照覆盖。 **代价**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);协议仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地结算、服务器侧轮次会继续运行到进程清理为止;快照 fixture 录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml index aec4a3554d..9fc614eed0 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.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/feature/2026-07-29-directory-picker-adaptive-default.md -2026-07-29-directory-picker-adaptive-default.md: 7ff6529bb8e445f63343b1019ac520f56b19d5e4 -2026-07-29-directory-picker-adaptive-default.zh.md: ba2bb4424416557b119accc7df14fab4c94ddebf +2026-07-29-directory-picker-adaptive-default.md: 92eb2b5ab4fb1312f03537d891b9f4afff6c7f22 +2026-07-29-directory-picker-adaptive-default.zh.md: a3d0c4b79950ef3452ac906b4a8917321cec6829 diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md index 7ff6529bb8..92eb2b5ab4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.md @@ -19,7 +19,7 @@ Why entry-level mounting is the load-bearing mechanism: the client module table - **Boot-glue resolution in `AppCLIEntry`** (ship both rows with static `disabled`, patch `disabled` from a `--directory-picker=auto|native|browse` flag). Works — `PatchOptions` patches metadata, and the modules scan skips disabled rows — but leaves the decision app-private where every future composition re-implements it; the chooser plugin gives any `cordis.yml` the same one-row adaptivity. Reintroduce the flag only when a deployment needs to *force* a backend without editing its yml. - **One merged plugin branching per call** (client tries `pick`, falls back to the browse dialog on `directory-picker-unavailable`). Rejected: the client would need both flows in one bundle — the bundle-purity gate forbids cross-plugin value imports and jscpd forbids copying the dialog — and per-call probing pays a doomed RPC on every open of a browse host. - **Resurrecting the wire advertisement** so both client flows mount and branch on the host's kind. Rejected: reverses the seam note's deletion for no consumer the chooser doesn't already serve, and collides with the `single` directory-flow holes. -- **Per-connection adaptivity** (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no deployment serves both operator shapes at once today. +- **Per-connection adaptivity** (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no shipped deployment serves both operator shapes at once. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md index ba2bb44244..a3d0c4b799 100644 --- a/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-directory-picker-adaptive-default.zh.md @@ -19,7 +19,7 @@ Status: implemented - **在 `AppCLIEntry` 里做启动胶水判定**(随附两行并带静态 `disabled`,由 `--directory-picker=auto|native|browse` 标志修补 `disabled`)。可行——`PatchOptions` 能修补元数据,模块扫描也会跳过禁用行——但把决策留成应用私有,此后每个组合都要重新实现;选择器插件让任何 `cordis.yml` 都获得同样的一行自适应。只有当某个部署需要不改自己的 yml 就*强制*指定后端时,才重新引入该标志。 - **合并成一个按调用分支的插件**(client 先试 `pick`,收到 `directory-picker-unavailable` 再回退到浏览对话框)。否决:client 得把两套流程装进同一个 bundle——bundle 纯净门禁禁止跨插件的值导入,jscpd 禁止复制对话框——而且按调用探测让 browse 宿主每次打开都付出一次注定失败的 RPC。 - **复活 wire 广播**,让两套 client 流程都挂载并按宿主的 kind 分支。否决:推翻 seam Agent Note 的那次删除,却服务不了任何选择器尚未服务的消费方,还与 `single` 目录流洞相冲突。 -- **按连接自适应**(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;今天没有部署同时服务两种操作者形态。 +- **按连接自适应**(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;没有已交付部署同时服务两种操作者形态。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index ab658f689f..df7b3d700b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 982b0b87553e15fab8fd6a2718dbe2462cfb3bd9 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 05935f8164018d0c476f85ccc5e6c0e87890f979 +2026-07-29-persistent-bash-str-replace-editor.md: e8e37b7e534773429a9c6fe0f63bb8d5460de364 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 71034ba615e09e09ec03212b6d5535959df73f4a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 982b0b8755..e8e37b7e53 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. +The shipped [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 05935f8164..71034ba615 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 +随附的 [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index c78d61ea2a..ea039e9ba6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: 465a6fc9e2fbe61fd1a2e6f11590d01e2553d506 -2026-07-30-web-diff-card.zh.md: 44a719a8e3ab83700fb7e293867ed75256868d94 +2026-07-30-web-diff-card.md: 42a3cc527824d29fce98a04bd2e2037cff93109e +2026-07-30-web-diff-card.zh.md: 35d169b254f7242f1cf6b6301d0deb8728470cb8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 465a6fc9e2..42a3cc5278 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -30,7 +30,7 @@ The chat row renders the diff resident under its path-link summary, capped at `C ## Alternatives considered -**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign. +**A side-by-side (two-column) diff.** Rejected: it is denser but does not fit the narrow chat row, and the selected design matches the TUI's single-column unified form. A two-column details-panel mode remains separable from this card design. **Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this change stays a pure Web consumer of the existing contract. @@ -40,7 +40,7 @@ The chat row renders the diff resident under its path-link summary, capped at `C `DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser. -The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer. +The multi-file arm of `DiffBlock` (one card, several path headers) has no shipped producer: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index 44a719a8e3..35d169b254 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -30,7 +30,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX ## Alternatives considered -**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。 +**并排(双栏)diff。**不予采纳:它更密,但不适合狭窄的 chat 行,而所选设计与 TUI 的单栏统一形式一致。详情面板中的双栏模式可以与本卡片设计分开引入。 **git 式行号槽。** `FileDiff` 约定只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端约定改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本变更保持为对既有约定的纯 Web 消费。 @@ -40,7 +40,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX `DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。 -`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。 +`DiffBlock` 的多文件支路(一张卡、多个路径头)没有已交付生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index 31468d0ea3..f5724092a0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.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/feature/2026-07-30-web-result-card.md -2026-07-30-web-result-card.md: 591471d219295019d28b9eeaf2e57f6d2115d380 -2026-07-30-web-result-card.zh.md: c850c148ba32ca782fd1d6d7e5c3bbfa758391c5 +2026-07-30-web-result-card.md: 35ad06998136cbffcf4a049cb0c68adb97498b68 +2026-07-30-web-result-card.zh.md: 81656b5f659996d8a30ee293ccbbf562c7e6dd85 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index 591471d219..35ad069981 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -22,7 +22,7 @@ Neither result view carries a `content` copy. A UI that does not render the stru ## Consequences -The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. +The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. Session Controller carries the event's typed `surfaceOp` without redeclaring card tags ([wire type](../../../../packages/api/session-controller/src/types.ts)), so the new view crosses the wire without a schema change. A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index c850c148ba..81656b5f65 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -22,7 +22,7 @@ Status: implemented ## Consequences -前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 +前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。Session Controller 直接携带事件中已类型化的 `surfaceOp`,不重新声明 card 标签([线路类型](../../../../packages/api/session-controller/src/types.ts)),因此新视图无需 schema 变更即可跨 wire。 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 6165c9d3d6..651c076892 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 429fbe9b80b965a1ce12f1b28ae0f3357ea07db8 -2026-07-31-even-out-shipped-tool-rosters.zh.md: 5893400d6d6d3ff30229ff57b9f1fcfae49acc84 +2026-07-31-even-out-shipped-tool-rosters.md: 97a9fdaedb97de77c195c319f14f972aac850726 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 130573f0ddf0b94b4dcb017f58f1e0935e53e844 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 429fbe9b80..97a9fdaedb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -34,7 +34,7 @@ Withholding it narrows the surface without removing the reach: `bash` is mounted `@deepseek-ai/dsh-mcp-client` becomes a runtime dependency of the CLI without a row in any shipped config. The plugin mounts exactly one server per instance and `command` is required, so a default would have to name a third-party server and spawn it as a child process on every launch — outside `ctx.shell`, and therefore outside the sandbox policy the Web surface composes. -The layer that would make MCP a default is the one this repository does not have yet: a bridge that reads a user's server list and mounts one client per entry, the same shape [`dsh-hooks-claude-code`](../../../../packages/hooks/hooks-claude-code/README.md) already has for a Claude Code `hooks.json`. Shipping the dependency means an installed `dsh` can mount servers from `$DSH_HOME/config.yaml` today; the CLI README carries the YAML. +The layer that would make MCP a default is the one this repository does not have yet: a bridge that reads a user's server list and mounts one client per entry, the same shape [`dsh-hooks-claude-code`](../../../../packages/hooks/hooks-claude-code/README.md) already has for a Claude Code `hooks.json`. Shipping the dependency means an installed `dsh` can mount servers from `$DSH_HOME/config.yaml`; the CLI README carries the YAML. ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index 5893400d6d..130573f0dd 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -34,7 +34,7 @@ Status: implemented `@deepseek-ai/dsh-mcp-client` 成为本 CLI(命令行界面)的运行时依赖,但在任何交付配置里都没有对应的行。该插件每个实例只挂载一台服务器,且 `command` 是必填,因此一个默认值必须点名一台第三方服务器,并在每次启动时把它作为子进程 spawn——不经 `ctx.shell`,因而也在 Web surface 所组合的沙箱策略之外。 -真正能让 MCP 成为默认的那一层,恰恰是本仓库尚未拥有的:一个读取用户服务器清单、按条目逐台挂载客户端的桥接,形态与 [`dsh-hooks-claude-code`](../../../../packages/hooks/hooks-claude-code/README.zh.md) 读取 Claude Code 的 `hooks.json` 完全相同。交付这个依赖意味着已安装的 `dsh` 今天就能从 `$DSH_HOME/config.yaml` 挂载服务器;CLI README 里给了那段 YAML。 +真正能让 MCP 成为默认的那一层,恰恰是本仓库尚未拥有的:一个读取用户服务器清单、按条目逐台挂载客户端的桥接,形态与 [`dsh-hooks-claude-code`](../../../../packages/hooks/hooks-claude-code/README.zh.md) 读取 Claude Code 的 `hooks.json` 完全相同。交付这个依赖意味着已安装的 `dsh` 能从 `$DSH_HOME/config.yaml` 挂载服务器;CLI README 里给了那段 YAML。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml index ac34aaa150..b82a2e1fa9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.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/feature/2026-07-31-gui-full-access-confirmation.md -2026-07-31-gui-full-access-confirmation.md: 5822ae26d36329b865a05c59dd672daa55fb6224 -2026-07-31-gui-full-access-confirmation.zh.md: 612300dccaf8aa5d75b223767350f769037ea0f1 +2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83 +2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md index 5822ae26d3..f63502cd3e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md @@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss ## Decision -**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the locale product label for full access; every dismissal path submits nothing.** +**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.** - `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed. - The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys. - The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending. - The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched. -- The full-access product label overrides the kebab-to-title display transform when the host keeps the canonical built-in name; an explicit host label wins. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. The same conditional localization applies to the safer shipped presets ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)). +- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md index 612300dcca..f4b3686d1e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以完全权限的本地化产品标签展示;所有取消路径都不作任何提交。** +**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。** - `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。 - composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。 - `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。 - 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。 -- 当 host 保留内置预设的规范名称时,完全权限产品标签会覆盖 kebab 转 Title Case 的显示变换;显式 host 标签优先。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。同一个条件式本地化规则也适用于更安全的随附预设([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md))。 +- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index 4b56c3fa98..aaa3cc3cc8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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/feature/2026-07-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 818c105ce880f7b67bfd28030502bf6775e1a039 -2026-07-31-permission-default-for-new-sessions.zh.md: 980b007a4d246da36446e79cd089a7c513f22c85 +2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd +2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 818c105ce8..ebf7fe3971 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t `dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them. -The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records that the selection came from the default. Those facts pin the permission selected at creation, so a later Settings change does not change started conversations. When Web later confirms a Workspace blank for New Session reuse, the host explicitly adopts that live or cold session and advances it only if its latest selection remains default-origin and effective; this refinement is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). An ordinary seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default merely by resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. +The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. @@ -20,7 +20,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c ## Consequences -Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter an existing session by itself. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. A Workspace blank may receive a new pinned triplet only when Web confirms it as the reuse target and its effective selection is still default-origin. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. +Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. The assembled Web snapshot contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `workspace-write` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 980b007a4d..c0f450c8ef 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` `dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。 -服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实会记录该选择来自默认值。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。Web 之后把某个 Workspace 空白会话确认为新会话复用目标时,host 会显式接纳这个 live 或冷会话,并且只在其最近选择仍来自默认值且继续有效时推进;这项细化记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md)。普通的带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;仅仅恢复会话时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。 +服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 @@ -20,7 +20,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 ## 后果 -在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但单凭该操作不会改变既有会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。只有当 Web 把某个 Workspace 空白会话确认为复用目标,并且其有效选择仍来自默认值时,该会话才可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 +在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml index ec1a03cf83..1ba8316340 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.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/feature/2026-07-31-workspace-write-surface-default.md -2026-07-31-workspace-write-surface-default.md: e096028e07f1e7905fef568c435ad7f3d1d19138 -2026-07-31-workspace-write-surface-default.zh.md: d5b1a15ccb0acc1ebfe1df120af57e2c46d5aeb6 +2026-07-31-workspace-write-surface-default.md: 2ce4d7b45ad7058768f412302eb86ac332cabfe9 +2026-07-31-workspace-write-surface-default.zh.md: 40b97812eb17b5c11e422ff23cd718576c9c4364 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md index e096028e07..2ce4d7b45a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md @@ -12,7 +12,7 @@ The shipped terminal and browser surfaces exposed the same coding tools under di [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission-presets`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam. -A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Changing the General-settings default affects fresh sessions, while started and ordinary resumed sessions retain their logged permission. A later Web New Session action may advance a default-origin Workspace blank after confirming and adopting that exact reuse target, as recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there. +A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there. The mode governs file effects only. Sandboxed bash and filesystem mutations admit the session workspace and platform temporary roots; reads, network access, and process visibility remain outside this policy. If no platform runner can enforce a confined bash call, execution fails closed instead of falling through to an unrestricted command. @@ -30,6 +30,6 @@ The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads ## Consequences -Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. A Settings write does not rewrite logged session permissions; confirmed reuse may append a new default-origin permission triplet to the selected blank Workspace session. +Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. Stored user defaults and logged session permissions are not rewritten. The browser-backed headless entry inherits the Web composition and therefore the same default. The TUI's missing approval answerer is a deliberate limitation of this change: automatic wider retries fail closed there instead of displaying a permission question. diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md index d5b1a15ccb..40b97812eb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md @@ -12,7 +12,7 @@ Status: implemented [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission-presets`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。 -真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。更改「通用」设置中的默认值会影响新建会话,而已经开始的会话和普通恢复会话保留日志中记录的权限。Web 之后执行新会话操作时,可以在确认并接纳确切复用目标后推进权限来自默认值的 Workspace 空白会话;该规则记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.zh.md)。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。 +真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。 该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝告终,不会退回不受限命令。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。Settings 写入不会改写会话日志中的权限;复用确认可以向选中的空白 Workspace 会话追加新的默认来源权限三元组。 +全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。 由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝告终,而不会显示权限询问。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 85e6ab91f4..ee12783b03 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 0ae12e54e574fb46035fb664f815b3edba706812 -2026-08-02-pwsh-tool-bash-parity.zh.md: f45812d735898be207115f863ab92ddbe760b358 +2026-08-02-pwsh-tool-bash-parity.md: 67107e90d5aa509f76468d5ddde9d36d4aabaa6c +2026-08-02-pwsh-tool-bash-parity.zh.md: 3857f4fffc7ba389e17c85903d2462032add8805 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 0ae12e54e5..67107e90d5 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Consequences -- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- The bash and pwsh tools are behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface is owned by the Windows ACL sandbox decision), and renderer coverage pins every pwsh prompt and description sentence. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-shell-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `shell-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index f45812d735..3857f4fffc 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- bash 与 pwsh 工具在前台、后台与沙箱化 shell 工作上行为可互换(沙箱面随 Windows ACL 沙箱决策到来),pwsh 的提示词/描述句每句都有渲染器背书——reviewer 的「拿代码 grep 对证」检查通过。 +- bash 与 pwsh 工具在前台、后台与沙箱化 shell 工作上行为可互换(沙箱表层由 Windows ACL 沙箱决策负责),渲染器覆盖固定了每一句 pwsh 提示词与描述。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture(测试前置数据)钉住。 - `@deepseek-ai/dsh-shell-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `shell-env`(主干组合包已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index a20606b5ed..6cc0c25b8e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941 -2026-08-02-web-thinking-tail-scroll.zh.md: 41fe29f06202aef3307d756201eb4745fecca537 +2026-08-02-web-thinking-tail-scroll.md: 38c27274b2c85974044c2bb467c1519e19bfd148 +2026-08-02-web-thinking-tail-scroll.zh.md: bf637228b3a793f318fa5a5d7b0968ecd8e081d5 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index b9aa47a01b..38c27274b2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index 41fe29f062..bf637228b3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 +`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 638d2c8f69..94d100bf69 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: ef9232051d0ff6e3e752f08a68e9f04ce3c32ad6 -2026-08-02-win32-in-process-folder-dialog.zh.md: d2bab277ca078b6943b720d7dcbfce4e8a548270 +2026-08-02-win32-in-process-folder-dialog.md: 52eab80499de9556cc0a395ac17f0f307e52131f +2026-08-02-win32-in-process-folder-dialog.zh.md: af45bc18d60a0165d6cc8d7a2b253289a91bdbac diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index ef9232051d..52eab80499 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -24,4 +24,4 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou - Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. - Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). - The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. -- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test. The source plane and built `lib/worker.cjs` under plain Node are covered; packaged self-spawn remains a named coverage gap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index d2bab277ca..af45bc18d6 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -24,4 +24,4 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` s - 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 - 真实对话框的渲染与完成选择的流程仍需在 Windows 上手动检查(自动关闭冒烟测试证明打开/中止/收尾)。 - 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md))。mocked-koffi 的 ABI 固定测试与真实 win32 冒烟测试正是为了在交付前捕获这类错误。 -- 打包二进制路径——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码侧与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 +- 打包二进制路径——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖。源码侧与普通 Node 下构建出的 `lib/worker.cjs` 已被覆盖;打包后的自我 spawn 仍是一项明确的覆盖缺口。 diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml index 019f84df2b..50a3a9609c 100644 --- a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.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/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md -2026-08-04-pointer-revealed-sidebar-scrollbars.md: 5c6fff378932d3e63bc92ed27e9b0341effaf6b8 -2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md: dd9528fec4bd24a53b17ecf888a9af03d457ce0e +2026-08-04-pointer-revealed-sidebar-scrollbars.md: 5a9a0b948ed97af37cab135928bb8624fb2a4da9 +2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md: 35350e1811da8a5e94a7b2eaefc6111d02b18c45 diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md index 5c6fff3789..5a9a0b948e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md @@ -10,7 +10,7 @@ The sidebar's session list overflows after a handful of sessions, and from that ## Decision -`SidebarRoot` tracks the pointer over the whole column and carries a `quietBars` class whenever it is outside. The rule that class selects rebinds ui-theme's indirection pair — `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` — to `transparent`, so every scroll region nested under the column draws no thumb. The session list is the only one today; a future one inherits the behavior rather than opting into it. +`SidebarRoot` tracks the pointer over the whole column and carries a `quietBars` class whenever it is outside. The rule that class selects rebinds ui-theme's indirection pair — `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` — to `transparent`, so every scroll region nested under the column draws no thumb. The session list is the only occupant; a future one inherits the behavior rather than opting into it. The tail is `SCROLLBAR_LINGER_MS = 2000`: leaving arms a timer, entering cancels a pending one, and only the timer firing puts the class back. A pointer that crosses the column's edge and returns — travelling around a portalled menu, or overshooting on the way to a row — never sees the thumb blink. @@ -45,7 +45,7 @@ Hiding no longer counts as elevating: only an l2 rebind exempts a sheet from "ev - A list scrolled by keyboard or by a touch drag shows no thumb once the linger passes, since neither leaves a pointer over the column. The e2e pins this rather than only describing it. - Dragging the thumb itself out of the column does not hide it mid-drag: the scrollbar takes the pointer capture, so the page receives no `pointermove` while the button is held. Measured in Chromium — the bar stays drawn and keeps scrolling with the pointer 900px to its right, past the linger window. - The column starts quiet on a cold load and stays so until the pointer first moves over it. A pointer already parked there when the page loads fires nothing until it moves, which is the browser's rule rather than this shell's. -- An elevated surface nested in the column that rebinds the pair to l2 for its own elevation overrides the quiet state and keeps its bar drawn. Nothing in the sidebar does this today. +- An elevated surface nested in the column that rebinds the pair to l2 for its own elevation overrides the quiet state and keeps its bar drawn. No sidebar surface does this. - The shell's DOM now carries a state class, so ui-sidebar's shell snapshots pin `quietBars` and a regression in the default state is a snapshot diff rather than something someone has to notice in a screenshot. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md index dd9528fec4..35350e1811 100644 --- a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`SidebarRoot` 跟踪整列上的指针,只要指针不在列内就给根元素挂上 `quietBars` 类。该类选中的规则把 ui-theme 的那组间接变量——`--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`——重新绑定为 `transparent`,于是嵌套在这一列下的每个滚动区域都不绘制滑块。今天这样的区域只有会话列表;将来新增的区域会直接继承这一行为,而不需要逐个接入。 +`SidebarRoot` 跟踪整列上的指针,只要指针不在列内就给根元素挂上 `quietBars` 类。该类选中的规则把 ui-theme 的那组间接变量——`--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`——重新绑定为 `transparent`,于是嵌套在这一列下的每个滚动区域都不绘制滑块。只有会话列表占用这样的区域;将来新增的区域会直接继承这一行为,而不需要逐个接入。 拖尾是 `SCROLLBAR_LINGER_MS = 2000`:离开会启动一个定时器,进入会取消尚未触发的定时器,只有定时器真正触发才会把类加回去。指针越过列边界又折返时——绕过一个 portal 菜单,或是奔向某一行时冲过了头——不会看到滑块闪动。 @@ -45,7 +45,7 @@ Status: implemented - 用键盘或触摸拖动滚动的列表,在拖尾结束后不显示滑块,因为这两种方式都不会把指针留在列上。e2e 会钉住这一点,而不只是把它写下来。 - 拖动滑块本身移出列不会在拖动中途把它隐藏:滚动条会接管指针捕获,按住按键期间页面收不到 `pointermove`。已在 Chromium 实测——指针拖到列右侧 900px 处、超过拖尾窗口后,滚动条依然绘制并继续滚动。 - 冷启动时该列处于静默状态,直到指针第一次移到它上面为止。页面加载时就停在那里的指针在移动之前不会触发任何事件,这是浏览器的规则,而非这个外壳的。 -- 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。今天侧边栏内没有这样的表面。 +- 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。侧边栏内没有这样的表面。 - 外壳的 DOM 现在带有一个状态类,因此 ui-sidebar 的外壳快照会钉住 `quietBars`,默认状态出现回归时表现为快照 diff,而不是需要有人从截图里看出来的东西。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index a56f2fed91..754b846347 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.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/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 06f6b7b4bd12e2dccb62a38a5b57b153a6263901 -2026-08-04-web-context-source-and-steer-marks.zh.md: d8109d832fdf6974a89427a6296b24054c6eac36 +2026-08-04-web-context-source-and-steer-marks.md: 0a9d6a4ebd1035773cd768af5ea8614bcad9e0c3 +2026-08-04-web-context-source-and-steer-marks.zh.md: 3e3f80cc54f57e870077b735c4dbb04b9b462656 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 06f6b7b4bd..0a9d6a4ebd 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -18,7 +18,7 @@ The Chat Message Definition attaches a `provenance` view containing the producer **The label is read out of the log, never from a client-side table of producer names.** `agent-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. -`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. +`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. `MessageItem` captions durable and pending steering bubbles with `插话`. The Chat Inbox and Message Definitions replay durable `agent/inbox/spliced` events and project a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index d8109d832f..3e3f80cc54 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -18,7 +18,7 @@ Chat Message Definition 为每个 `ContextMessageNode` 附加一份包含生产 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `agent-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码失准,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 -`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 +`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 Chat Inbox 与 Message Definition 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。`MessageItem` 为这种持久消息与待处理 steering 气泡加上 `插话` 标注。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml index 54df52a8e9..f1ed369ae8 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.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/feature/2026-08-04-web-latency-throughput-metrics.md -2026-08-04-web-latency-throughput-metrics.md: 4d7627a9a38127259f1ba07121cea7282f794f2e -2026-08-04-web-latency-throughput-metrics.zh.md: f0089cb45775f2dc9701a7256119f04e4709dd76 +2026-08-04-web-latency-throughput-metrics.md: 14ec765a7aadb8a54609c7d4e0e6af149c3c96ba +2026-08-04-web-latency-throughput-metrics.zh.md: 52e6c33c52895f5a075f78019c5f353ecc8cc4b4 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md index 4d7627a9a3..14ec765a7a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.md @@ -18,7 +18,7 @@ The stats line reuses the same step reading in its window fold: `deriveStats` ac ## Alternatives considered -**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is already documented as window-scoped for its duration facts — the new group joins that scope. A later PR can add the durable projection without moving these readings. +**A durable session projection (token-meter shape).** A `ProjectionDefinition` folding step timings host-side would survive compaction and window paging and cover the whole log. Deferred, not rejected: projection state must stay O(1) (averages, not percentiles), it needs a host change plus a schema, and the chat stats line is documented as window-scoped for its duration facts. A durable projection can be added without moving these readings. **Per-step footer chrome.** Showing each assistant message its own TTFT would attach chrome to mid-turn narration nodes, which the footer design deliberately keeps chrome-free; the trajectory view already exposes per-step timing detail. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md index f0089cb457..52e6c33c52 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-latency-throughput-metrics.zh.md @@ -18,7 +18,7 @@ assistant 页脚把读数追加到既有 hover 显示的时间附属元素中、 ## 考虑过的替代方案 -**持久的会话投影(token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩(compaction)与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema,而聊天统计行的耗时事实本就被记录为窗口作用域——新分组沿用该作用域。后续 PR(Pull Request)可以在不挪动这些读数的情况下补上持久投影。 +**持久的会话投影(token-meter 形态)。** 在 host 侧用 `ProjectionDefinition` 折算步骤计时可以跨越压缩(compaction)与窗口分页、覆盖整个日志。是暂缓而非否决:投影状态必须保持 O(1)(只能均值,不能分位数),它需要 host 改动加 schema,而聊天统计行的耗时事实被记录为窗口作用域。持久投影可以在不挪动这些读数的情况下补上。 **逐步骤页脚附属元素。** 让每条 assistant 消息显示自己的 TTFT,会给轮次中段的叙述节点挂上附属元素,而页脚设计刻意让它们保持无 chrome;trajectory 视图已经暴露逐步骤计时细节。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index a9895e1743..f125c62af7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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/feature/2026-08-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 27c36f999b676026ca09f4f0e226c8ed95ef4918 -2026-08-05-context-form-vocabulary.zh.md: 1aa874e09336f99b06d7ebc385e0ac0c195bacbf +2026-08-05-context-form-vocabulary.md: 9f20614dcd2ed0164efb51f938de9f74657d3bc3 +2026-08-05-context-form-vocabulary.zh.md: ebdabeab5a074f21e8fac9625a78c99cc401a6d3 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 27c36f999b..9f20614dcd 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -51,7 +51,7 @@ The tool presentation contract pairs its vocabulary with `presentCall(args)`, a **Map source kinds to renderers in the client.** Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the [source and steer marks decision](2026-08-04-web-context-source-and-steer-marks.md) removed for labels. -**Reuse `kind` as the form.** One discriminant is simpler, and `agent-instructions` is already 1:1 with its form. This design loses information when several producers share one form: three producers emit runtime snapshots today, and combining them into one kind would make it impossible to tell which producer supplied each message. Separate `kind` and `form` fields record the producer while allowing several producers to share one presentation. +**Reuse `kind` as the form.** One discriminant is simpler, and `agent-instructions` is already 1:1 with its form. This design loses information when several producers share one form: three shipped producers emit runtime snapshots, and combining them into one kind would make it impossible to tell which producer supplied each message. Separate `kind` and `form` fields record the producer while allowing several producers to share one presentation. **Let the client parse the model-facing prose.** The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 1aa874e093..ebdabeab5a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -51,7 +51,7 @@ Status: implemented **在客户端把来源 kind 映射到渲染器。** 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.zh.md)刚为名称去掉的那种耦合。 -**复用 `kind` 充当形态。** 单一判别字段更简单,`agent-instructions` 本来也与它的形态一一对应。但多个生产方共享同一形态时,这种设计无法保留完整信息:今天有三个生产方发出运行时快照,把它们并成一个 kind 后,将无法分辨每条消息由哪个生产方提供。独立的 `kind` 和 `form` 字段会记录生产方,同时允许多个生产方共用一种呈现方式。 +**复用 `kind` 充当形态。** 单一判别字段更简单,`agent-instructions` 本来也与它的形态一一对应。但多个生产方共享同一形态时,这种设计无法保留完整信息:三个已交付生产方发出运行时快照,把它们并成一个 kind 后,将无法分辨每条消息由哪个生产方提供。独立的 `kind` 和 `form` 字段会记录生产方,同时允许多个生产方共用一种呈现方式。 **让客户端解析面向模型的散文。** 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到提示词措辞上,于是每改一次文案就会悄悄破坏一张卡片——这也正是目录身份从文本上迁走的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml index d421c6ba82..50b48a757c 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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/feature/2026-08-06-mcp-client-auto-reconnect.md -2026-08-06-mcp-client-auto-reconnect.md: 99a8aec1abe3713822f8f17c17d8efaca5d61a4d -2026-08-06-mcp-client-auto-reconnect.zh.md: 87acb6901cf3bc03fc5d71e89538e5ec23b2b118 +2026-08-06-mcp-client-auto-reconnect.md: 0f9119c62087a32e516583b72f841387c05105c1 +2026-08-06-mcp-client-auto-reconnect.zh.md: 62be78821d7f6e489e613f698d77a438aa1e10c7 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md index 99a8aec1ab..0f9119c620 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -6,7 +6,7 @@ English | [中文](2026-08-06-mcp-client-auto-reconnect.zh.md) ## Problem -The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. When a stdio server crashed or was killed, its registered tools stayed visible but every call failed with `Not connected` until a human edited the config (HMR) or restarted the Host — v1 explicitly deferred reconnection. Long-running hosts (ACP automation, web) cannot be bounced because a child process died, and for stdio the harness composition is the only party that can respawn it. External feedback escalated this as a real operational gap (issue #1746). +The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. When a stdio server crashed or was killed, its registered tools stayed visible but every call failed with `Not connected` until a human edited the config (HMR) or restarted the Host — the connect-once decision explicitly deferred reconnection. Long-running hosts (ACP automation, web) cannot be bounced because a child process died, and for stdio the harness composition is the only party that can respawn it. External feedback escalated this as a real operational gap (issue #1746). ## Decision @@ -20,7 +20,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **Config and resolution.** Both transports accept `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` with schemastery defaults (on, 500ms, 30s, 10). `resolveReconnectPolicy()` is the explicit resolve step: it re-judges every bound and cross-field constraint because programmatic construction may bypass Schemastery, and misconfiguration fails the plugin instance at load. -**Observable states.** An initial or retry-attempt failure says `connection failed`; an established generation ending says `connection lost`. Retrying logs at warn with attempt count and delay, recovery at info, final failure and disabled recovery at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior. +**Observable states.** An initial or retry-attempt failure says `connection failed`; an established generation ending says `connection lost`. Retrying logs at warn with attempt count and delay, recovery at info, final failure and disabled recovery at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the connect-once manual-recovery behavior. **Disposal.** Dispose flips the fence, cancels any pending timer, closes the current client, then awaits the in-flight attempt and the sync queue before unregistering — quiescence, not just a request to stop. The reconnect timer is unref'd so a waiting backoff never holds a finishing process open. @@ -32,17 +32,17 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **Unregister tools immediately on disconnect, re-register on recovery.** Rejected: a transient outage would flap the model-visible tool list (two schema-prefix invalidations per crash) for no information gain; failing calls already signal the outage, and the swap on recovery is atomic per generation. Tools are unregistered at final failure so a permanently dead server does not leak permanently broken tools. -**Route Streamable HTTP request failures into the supervisor.** Rejected for now: the HTTP transport already reconnects its SSE stream with its own backoff, per-request errors do not imply a dead server, and there is no child process the harness could respawn. Transport close stays the single trigger. +**Route Streamable HTTP request failures into the supervisor.** Rejected: the HTTP transport already reconnects its SSE stream with its own backoff, per-request errors do not imply a dead server, and there is no child process the harness could respawn. Transport close stays the single trigger. **Restart through Loader/HMR machinery instead of an in-plugin supervisor.** Rejected: the Loader owns config-driven recomposition, not runtime health. A plugin restarting itself through the Loader would conflate config generations with connection generations and lose the per-outage budget. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, diagnostics distinguish initial or retry failure from established connection loss, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, diagnostics distinguish initial or retry failure from established connection loss, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps manual recovery, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. ## Consequences - A crashed stdio MCP server recovers without human intervention: bounded backoff, re-discovery, atomic generation swap. Default policy retries an outage for roughly 2.5 minutes before giving up. -- Connection state is genuinely more intricate than connect-once — the partial-availability window v1 avoided now exists (registered tools failing during an outage), concentrated in one module with the invariants named. +- Connection state is genuinely more intricate than connect-once. The design accepts a partial-availability window with registered tools failing during an outage, concentrated in one module with the invariants named. - `reconnect` is new config surface on both transports, and the stability window is deliberately derived from `maxDelayMs`; making it independently tunable is a compatible future change. - After final failure or with reconnect disabled, the plugin stays loaded with no (or failing) tools until reload — deliberate and logged, so a chronically broken server cannot restart forever. diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md index 87acb6901c..62be78821d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[MCP 客户端](2026-07-07-mcp-client-plugin.zh.md)在插件加载时仅连接一次。stdio 服务器崩溃或被终止后,其已注册的工具仍然可见,但每次调用均以 `Not connected` 失败,直到人工编辑配置触发 HMR(热模块替换)重载,或重启 Host——v1 明确推迟了重连机制。长时间运行的 Host(ACP 自动化、Web)不能因为子进程死亡就被重启;而对于 stdio 传输,harness 组合层是唯一能重新拉起子进程的一方。外部反馈将此升级为真实的运维缺口(issue #1746)。 +[MCP 客户端](2026-07-07-mcp-client-plugin.zh.md)在插件加载时仅连接一次。stdio 服务器崩溃或被终止后,其已注册的工具仍然可见,但每次调用均以 `Not connected` 失败,直到人工编辑配置触发 HMR(热模块替换)重载,或重启 Host——单次连接决策明确推迟了重连机制。长时间运行的 Host(ACP 自动化、Web)不能因为子进程死亡就被重启;而对于 stdio 传输,harness 组合层是唯一能重新拉起子进程的一方。外部反馈将此升级为真实的运维缺口(issue #1746)。 ## 决策 @@ -20,7 +20,7 @@ Status: implemented **配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 -**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 +**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持单次连接的手动恢复行为。 **资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。 @@ -32,17 +32,17 @@ Status: implemented **断连时立即注销工具,恢复时重新注册。** 否决:短暂故障会使模型可见工具列表抖动(每次崩溃触发两次 schema 前缀失效),而无任何信息增益;失败的调用已足以标示故障,恢复时的切换按代原子执行。工具仅在最终失败时注销,确保永久死亡的服务器不会泄漏永久失效的工具。 -**将 Streamable HTTP 请求失败路由到监督器。** 暂不采纳:HTTP 传输已使用自己的退避机制重连其 SSE 流,逐请求错误并不意味着服务器已死,且 harness 没有可重新拉起的子进程。transport 关闭仍是唯一触发条件。 +**将 Streamable HTTP 请求失败路由到监督器。**不予采纳:HTTP 传输已使用自己的退避机制重连 SSE 流,逐请求错误并不意味着服务器已死,且 harness 没有可重新拉起的子进程。transport 关闭仍是唯一触发条件。 **通过 Loader/HMR 机制重启,而非使用插件内监督器。** 否决:Loader 负责配置驱动的重组合,而非运行时健康管理。插件通过 Loader 重启自身会混淆配置代与连接代,并丢失逐故障预算。 ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、诊断区分初始或重试尝试失败与已建立连接丢失、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、诊断区分初始或重试尝试失败与已建立连接丢失、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持手动恢复、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 ## 后果 - 崩溃的 stdio MCP 服务器无需人工干预即可恢复:有界退避、重新发现、原子代切换。默认策略对一次故障大约重试 2.5 分钟后放弃。 -- 连接状态确实比一次性连接更复杂——v1 刻意回避的部分可用窗口现已存在(故障期间已注册工具返回失败),集中在一个模块中并命名了所有不变式。 +- 连接状态确实比一次性连接更复杂。该设计接受一段部分可用窗口:故障期间已注册工具会返回失败;相关不变式集中在一个模块中并被明确命名。 - `reconnect` 是两种传输上的新配置表面,稳定窗口刻意从 `maxDelayMs` 推导;将其设为独立可调参数是兼容的未来变更。 - 最终失败后或禁用重连时,插件保持加载状态但无(或失败的)工具,直到重新加载——行为是刻意的且有日志记录,确保长期故障的服务器不能永远重启。 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml index 55ce6ceeb9..e340548145 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.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/feature/2026-08-06-web-skill-tool-row.md -2026-08-06-web-skill-tool-row.md: 04e8ffcd6e506142b569e60458af144233810bbd -2026-08-06-web-skill-tool-row.zh.md: c68ddea1d44e3b071deafc9450f6a2d09023cd80 +2026-08-06-web-skill-tool-row.md: a78afd5d375d2cecd806ade663b4a5b7511ae279 +2026-08-06-web-skill-tool-row.zh.md: 469f9510ea5f31ceb8fb177432b090d91c8a5df8 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md index 04e8ffcd6e..a78afd5d37 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.md @@ -14,7 +14,7 @@ The Web transcript renders `skill` calls through the generic fallback row, so a The collapsed row uses a 14-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card. -The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. +The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot. The snapshot removes a calendar date only when it immediately precedes the normalized clock and `Ran for` footer; the clock remains, so hosts in different timezones produce the same snapshot at a same-day boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md index c68ddea1d4..469f9510ea 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-skill-tool-row.zh.md @@ -14,7 +14,7 @@ Web transcript(文本记录)通过通用后备行渲染 `skill` 调用,使 收起的行使用 14 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色,skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。 -该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议约定。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。 +该行的所有可见值均派生自当前 runtime 窗口中已配对的调用/结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议约定。现有的 ACP(Agent Client Protocol)`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。快照只会在日历日期紧邻规范化时钟与 `Ran for` 页脚时移除该日期;时钟仍保留,使不同时区的宿主在同日边界产出相同快照。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml deleted file mode 100644 index 29c39a1018..0000000000 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md -2026-08-07-workspace-picker-composer-entry.md: dc9c26c291de6e7614ace3c787030c0032a9740d -2026-08-07-workspace-picker-composer-entry.zh.md: 9121750ae1cbef2a94630d9ac43a765c03a397a7 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 0b96758e87..1ee7b9dfc3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.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/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 25352ec4f137f0f40c04c90c5c578dcd73eb4e71 -2026-08-08-web-background-job-display.zh.md: f540c65b51a9d23e2f7d286e74cb5cec7356acf7 +2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 +2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 25352ec4f1..8da6c2fd91 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -14,24 +14,24 @@ The session header was already the place where per-session background activity l ## Decision -Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. +Task state reaches the browser as **one whole-snapshot control frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. ### Wire shape -One frame in the mux stream: +One frame in the Session Controller control stream: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts), alongside the other domain contracts, with its wire schema beside it in `jobs.schema.ts`: +`SessionJob` is browser-safe and owned beside the other Session Remote contracts in [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts): ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { Three `JobSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). -The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. +The frame carries a whole snapshot rather than a delta so start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. ### The task-registry change feed @@ -66,16 +66,16 @@ The listener is owner-granular rather than task-granular. The only consumer push Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. -### The api-proxy carrier +### The Session Controller carrier -`mux()` subscribes `ctx.jobs.onJobsChanged` and pushes `session/jobs`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) emits one complete Host-wide baseline before later `jobs` replacement frames. Every physical reconnect opens a new generation, so the client replaces its process-local mirror before applying further changes. Four rules the carrier keeps: -- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. -- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. -- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. -- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. +- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))`, where a Session with no live Agent correctly yields only unowned tasks. Neither path calls the [Session Controller Agent resolver](../../../../packages/api/session-controller/src/agent.ts), because listing must never revive a Session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every attached Session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry reports empty job sets, and the client renders no entry point. +- **Represent emptiness explicitly.** The opening baseline contains an entry for every attached Session, including `[]`; a later change that empties one list also pushes `[]`. The client may then normalize an empty set to an absent key without retaining stale rows. ### The client mirror @@ -83,7 +83,7 @@ Four rules the carrier keeps: It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. -Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. +Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering. ### The header action @@ -95,13 +95,13 @@ A running one-shot background subagent therefore appears both there and in the s **No web path calls `ctx.jobs.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `job_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site. -**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against today's contract would leave the model believing its task is still running. +**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. **No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader. ## Alternatives considered -**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. **Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `JobRegistry`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. @@ -117,7 +117,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences @@ -129,7 +129,7 @@ Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts **Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. -**`stopping` is nearly unreachable today.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. +**`stopping` is rarely visible.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. **Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index f540c65b51..0e05ef9d2f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -14,24 +14,24 @@ Status: implemented ## 决策 -任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 +任务状态以**每会话一帧的整份 control 快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 ### 线路形状 -mux 流中的一帧: +Session Controller control 流中的一帧: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `jobs.schema.ts`: +`SessionJob` 是浏览器安全类型,与其他 Session Remote 约定一起由 [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts) 拥有: ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { `JobSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 -这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 +这一帧带整份快照而非增量,因此启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 ### 任务注册表变更订阅 @@ -66,16 +66,16 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 -### api-proxy 载体 +### Session Controller 载体 -`mux()` 订阅 `ctx.jobs.onJobsChanged` 并推送 `session/jobs`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) 先发出一份完整的 Host 范围 baseline,再发送后续 `jobs` 替换帧。每次物理重连都会打开新一代流,因此客户端会先替换进程本地镜像,再应用后续变更。 载体守着四条规则: -- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 -- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 -- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 -- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`,没有 live Agent 的 Session 正确地只得到无主任务。两条路径都不调用 [Session Controller Agent 解析器](../../../../packages/api/session-controller/src/agent.ts),因为列出任务绝不能复活用户随手划过的 Session。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已挂接 Session 推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合报告空任务集,客户端也就不渲染入口。 +- **显式表示空集。** opening baseline 为每个已挂接 Session 提供一项,包括 `[]`;后续变更清空一个列表时也会推送 `[]`。客户端因此可以把空集归一化为缺失键,而不会保留陈旧行。 ### 客户端镜像 @@ -83,7 +83,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 -两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 +两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。 ### header 入口 @@ -95,13 +95,13 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void **没有任何 Web 路径调用 `ctx.jobs.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `job_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。 -**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。 +**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。 **帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。 ## 备选方案 -**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 **只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `JobRegistry` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 @@ -117,7 +117,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 @@ -129,7 +129,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void **终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 -**`stopping` 今天几乎不可达。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 +**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 **一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index e7438665da..2d2fc47c20 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.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/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 860ed18c8be2e0dab00631307e2c30fdcae12692 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 6abbbeab49fc4eb3a1cb23cdbcc7084d5486a73a +2026-08-08-windows-acl-restricted-token-sandbox.md: f01c2f18cde78a561ccdd289a117f25916550ea4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 99e8a27f72544d01adb97d86c8397c562b171458 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 860ed18c8b..f01c2f18cd 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -28,7 +28,7 @@ An AppContainer token carries no ambient read access: every readable path must b ### Why not landstrip? -The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem. +The landstrip evaluation was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 6abbbeab49..99e8a27f72 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -28,7 +28,7 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ### 为什么不选 landstrip? -[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。 +landstrip 评估在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml index f116b7ac6d..6b942107c6 100644 --- a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.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/feature/2026-08-09-parallel-subagent-delegations.md -2026-08-09-parallel-subagent-delegations.md: 8601b997fbe67f7a3694e5963792ea9144fdd5f5 -2026-08-09-parallel-subagent-delegations.zh.md: d981388c8f9102b936fda650673fd4756d893333 +2026-08-09-parallel-subagent-delegations.md: 7e6f933749b3a65286ab4c428fc3e7d682e108ad +2026-08-09-parallel-subagent-delegations.zh.md: 1072697355a8a8a5d1b585f6d984063d9aca5617 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md index 8601b997fb..7e6f933749 100644 --- a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md @@ -24,7 +24,7 @@ Capacity stays where the scheduler note put it: `maxParallelToolCalls` caps one Package tests pin the classifier for both call forms. A gate test drives the registry directly with two children that each block until both have started, proving the half the declaration depends on: the tool body and provider start path tolerate concurrent dispatch — hidden serialization in that stack would deadlock instead of passing silently. A continuable gate holds two provider preparations at the same await, cancels one caller before publication, and proves that the cancelled child leaves no Agent or durable Session while its sibling reaches inbox acceptance and persists independently. The scheduling half, classification actually producing overlap, is owned by the classifier pin and the snapshot below. -The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay race-free today. +The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay deterministically. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md index d981388c8f..1072697355 100644 --- a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md @@ -24,7 +24,7 @@ Status: implemented 包测试固定了两种调用形态的分类器。一个门控测试直接驱动注册表,其两个子 agent 各自阻塞,直到两者都已启动,以此证明该声明所依赖的那一半:工具体和提供方启动路径能容忍并发分发——这条栈中任何隐藏的串行化都会造成死锁,而不是静默通过。一个可继续门控测试让两项提供方准备停在同一个 await 上,在发布前取消其中一个调用方,并证明已取消的子 agent 不会留下 agent 或持久会话,而其同级则到达 inbox 接受状态并独立持久化。另一半(分类真正产生重叠执行)由分类器 pin 测试和下述快照负责。 -人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此目前只有可互换的孪生委派才能无竞态地回放。 +人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此只有可互换的孪生委派才能确定性回放。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml deleted file mode 100644 index 08233cc4f0..0000000000 --- a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md -2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22 -2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index dcd01fc6d3..b0c70c4df4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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/feature/2026-08-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: a43e53d70e98bac7a50aa6bbabbb1e177237df01 -2026-08-10-minimal-read-image-tool.zh.md: a94e4b296425ad50876b0b45a689442c896a85a1 +2026-08-10-minimal-read-image-tool.md: 19306a35fe709a04d94090a62056575b4d51f7bc +2026-08-10-minimal-read-image-tool.zh.md: c7562c433e909d1f81361c0ced56318795e6469e diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index a43e53d70e..19306a35fe 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -6,28 +6,27 @@ English | [中文](2026-08-10-minimal-read-image-tool.zh.md) ## Problem -The multimodal attachment work gave user uploads a complete durable path — bytes committed to the content-addressed attachment store before the owning `user/message`, an `ImageBlock` carrying only the `sha256:` reference, and the pi-ai route re-reading verified bytes per request — but the model itself had no way to look at an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or a rendered chart either failed or shelled out to lossy workarounds. A first standalone attempt (PR #598) solved this together with loop-level route scoping: an `agent/request-ready` extension point publishing exact-model modalities before assembly, per-route schema/guidance visibility, and a reversible `image-placeholder-v1` history projection so text routes could continue over placeholder text. That design worked but coupled a tool to new agent-loop machinery, three new session-log concepts, and per-step registration churn — far more surface than the capability needs. +The multimodal attachment work gave user uploads a complete durable path, but the model itself had no way to inspect an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or rendered chart either failed or used a lossy workaround. A standalone attempt in PR #598 combined the tool with loop-level route scoping, per-route schema visibility, and new session-log concepts. Those features were not required to publish a logged image tool result. ## Decision -Ship the smallest tool that loads an image into the next request's context, entirely over existing seams; the withdrawn PR #598 design is the explicit counter-example this note records. +Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged tool results over existing extension points. -- **`read_image` lives in `dsh-tool-fs`** beside `read`/`write`/`edit`. Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`, and the tool result is the metadata envelope plus a real `ImageBlock` — `ToolResultBlock.content` already admits image blocks, the pi-ai adapter already renders them, and the Web host's model-switch guard already scans tool results, so nothing downstream changes. +- **`read_image` reads a filesystem path.** Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`. The tool result contains metadata and an `ImageBlock`. - **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). -- **Registration is composition-conditional, execution is route-gated.** The tool registers only under `ctx.inject(['attachments'], …)` — no store, no tool. At execution, before any I/O, the strict gate resolves the calling route (latest `request/header` config, falling back to agent options) through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A refusal is a plain `isError` result, so a text route's durable history never acquires an image block and the session cannot brick its own route. +- **Registration is composition-conditional, execution is route-gated.** The tools register only under `ctx.inject(['attachments'], …)`. Before I/O, the strict gate resolves the calling route through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A text-only route can still consume prior durable images because the shared LLM runtime projects them to placeholders at request assembly. - **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. -- **llm-replay models may declare `inputModalities`**, which is what lets the two keyless ACP snapshots pin both sides of the gate — the sha256-referenced success on an image-capable replay route and the verbatim refusal on a text-only one. +- **llm-replay models may declare `inputModalities`**, which lets keyless ACP snapshots cover the image-capable result and the text-only refusal. ## Alternatives considered -- **PR #598's route-scoped design** (request-ready seam, per-route schema/guidance visibility, reversible history projection) — withdrawn in favor of this note's shape. What it bought: text routes could keep running after images entered history, and the tool disappeared from prompts where it cannot succeed. What it cost: agent-loop changes, three new durable concepts (`agent/request-ready`, `messageProjection`, availability notices), and registration that churned per step. The capability itself — see an image on the next request — never needed any of it. If per-route projection becomes a real requirement, that PR's history is the reference implementation. +- **PR #598's route-scoped design** used a request-ready extension point, per-route schema visibility, reversible projection, and three durable concepts. Shared LLM request projection now handles text-only routes without putting tool registration or session formats into agent-loop. - **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end. - **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. - **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary. ## Consequences -- A text-only route refuses instead of degrading: no placeholder projection means no delegated-viewing story here — that is deliberately the next PR (subagent image readback rebuilt on the current subagent seams). -- The route gate races a concurrent model switch; the Web host's image-aware switch guard covers its surface, and other front doors own their equivalent. Recorded as a tool-fs Known Limitation. -- Repeated image results accumulate request-token cost until compaction; content addressing deduplicates bytes only. +- The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders. +- Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes. - The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index a94e4b2964..c7562c433e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -6,28 +6,27 @@ Status: implemented ## 问题 -多模态附件工作为用户上传建立了完整的持久路径:字节在所属 `user/message` 之前提交到内容寻址的附件存储,`ImageBlock` 只携带 `sha256:` 引用,pi-ai 路由在每次请求时重新读取并校验字节。但模型自己没有查看磁盘图像的手段。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么退到有损的变通做法。第一次独立尝试(PR #598)把这个问题与循环级路由作用域一起解决:新增在组装前发布确切模型模态的 `agent/request-ready` 扩展点、按路由控制 schema/指导可见性,以及可逆的 `image-placeholder-v1` 历史投影让文本路由能在占位符上继续。该设计可行,但让一个工具耦合了新的 agent-loop 机制、三个新的会话日志概念和每步的注册变动,远超这项能力本身的需要。 +多模态附件工作为用户上传建立了完整的持久路径,但模型无法查看磁盘图片。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么使用有损的变通方法。PR #598 的独立尝试把工具与循环级路由作用域、按路由控制 schema 可见性和新的会话日志概念放在一起。这些能力不是发布一条带图片且已记录的工具结果所必需的。 ## 决定 -只交付能把图像载入下一次请求上下文的最小工具,完全建立在既有 seam 之上;撤回的 PR #598 设计是本记录明确保留的反例。 +两个图片读取操作都放在 `dsh-tool-fs`,通过现有扩展点发布普通的持久工具结果。 -- **`read_image` 放在 `dsh-tool-fs`**,与 `read`/`write`/`edit` 并列。扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型;附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动,工具结果是元数据信封加真正的 `ImageBlock`——`ToolResultBlock.content` 本就允许图像块,pi-ai 适配器本就会渲染它们,Web 宿主的模型切换防护本就会扫描工具结果,下游无需任何改动。 +- **`read_image` 读取文件系统路径。** 扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型,附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动。工具结果包含元数据和一个 `ImageBlock`。 - **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 -- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册——没有存储就没有工具。执行时在任何 I/O 之前,严格门禁通过 `ctx.llm.resolveModelInfo` 解析调用路由(最新 `request/header` 配置,缺失时回退到 agent 选项),要求 `inputModalities` 包含 `image`;能力未知即拒绝。拒绝是普通的 `isError` 结果,因此文本路由的持久历史绝不会出现图像块,会话不会毁掉自己的路由。 +- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册。执行时在 I/O 之前通过 `ctx.llm.resolveModelInfo` 解析调用路由,并要求 `inputModalities` 包含 `image`;能力未知即拒绝。纯文本路由仍可使用此前的持久图片,因为共享 LLM 运行时会在请求组装时把图片投影为占位符。 - **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 -- **llm-replay 模型可以声明 `inputModalities`**,这正是两个 keyless ACP 快照能钉住门禁两侧的原因:图像路由上以 sha256 引用的成功结果,和纯文本路由上逐字的拒绝。 +- **llm-replay 模型可以声明 `inputModalities`**,因此 keyless ACP 快照可以覆盖支持图片的结果和纯文本拒绝。 ## 考虑过的替代方案 -- **PR #598 的路由作用域设计**(request-ready 扩展点、按路由的 schema/指导可见性、可逆历史投影)——被本记录的形态取代后撤回。它换来的是:图像进入历史后文本路由仍能运行,工具在注定失败的提示词里消失。它付出的是:改动 agent-loop、三个新的持久概念(`agent/request-ready`、`messageProjection`、可用性通知)和每步变动的注册。而这项能力本身——下一次请求看到图像——从不需要这些。如果按路由投影将来成为真实需求,该 PR 的历史就是参考实现。 +- **PR #598 的路由作用域设计**使用 request-ready 扩展点、按路由控制 schema 可见性、可逆投影和三个持久概念。共享 LLM 请求投影现在可以处理纯文本路由,无需把工具注册或会话格式放进 agent-loop。 - **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 - **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。 - **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 ## 后果 -- 纯文本路由得到拒绝而不是降级:没有占位符投影意味着这里没有委托查看的方案——那有意留给下一个 PR(基于当前 subagent seam 重建的 subagent image readback)。 -- 路由门禁与并发模型切换存在竞态;Web 宿主的图像感知切换防护覆盖其表面,其他前端拥有各自的等价防护。已记入 tool-fs 的已知限制。 -- 重复的图像结果在压缩之前持续累积请求 token 成本;内容寻址只去重字节。 +- 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。 +- 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。 - 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index 496cec7c35..29dfe4c8aa 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.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/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: 218d5ed4536c489f89cb53c62c457a988e451b7e -2026-08-10-web-plugin-configuration.zh.md: a1da262f728c823feb94ac60ad785379bdf49230 +2026-08-10-web-plugin-configuration.md: 8b8d231fd2920612c7d9d8c146253c1cb6e1c8a0 +2026-08-10-web-plugin-configuration.zh.md: 70a3b68730a750f03187e5a17e31765b36b8c879 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index 218d5ed453..8b8d231fd2 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -49,4 +49,4 @@ Two costs are real. Adding a fourth plugin still requires an entry in the apipro The bash and pwsh executors now expose `config` as a getter over a source thunk rather than a readonly field. Every read site was already per-call, so nothing else changed, but a subclass that captured `this.config` at construction would silently pin the composition entry. -`verify-cordis-config` gained one check, paid for by this branch: merging master's rename of the client manifest field (`dshClient` → `dsh.client`) left this package declaring the old name, and the whole section vanished from the browser with no error anywhere — the row composed, the empty node half activated, and the browser roster scan simply never matched it. Nothing could catch that, because the composition file cannot tell a surface plugin from a Host plugin: the difference lives in the manifest. The gate now requires a `packages/client` package's `./client` export and its `dsh.client` declaration to agree in both directions. The check is scoped to that group because a Host package's `./client` export is the typed wire face its browser consumers import, not a plugin the roster serves. +`verify-cordis-config` requires every `packages/client` package's `./client` export and `dsh.client` declaration to agree in both directions. Without that check, a stale manifest field can leave the composition row and empty node half active while the browser roster silently omits the package. The check is scoped to that group because a Host package's `./client` export is the typed wire face its browser consumers import, not a plugin the roster serves. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index a1da262f72..70a3b68730 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -49,4 +49,4 @@ Status: implemented bash 与 pwsh 执行器现在把 `config` 暴露为 source thunk 之上的 getter,而不再是 readonly 字段。所有读取点本就是按次读取,因此别无变化;但若某个子类在构造期捕获 `this.config`,就会悄然把组装条目钉死。 -`verify-cordis-config` 新增一项检查,代价由本分支付过:合并 master 对客户端清单字段的重命名(`dshClient` → `dsh.client`)后,本包仍声明旧名,于是整个分区从浏览器上消失,且任何地方都不报错——行照常组装、空的 node 半侧照常激活,只是浏览器 roster 扫描永远匹配不到它。这一点无从被既有门禁发现,因为组装文件区分不了 surface 插件与 Host 插件:差别在清单里。现在门禁要求 `packages/client` 包的 `./client` 导出与 `dsh.client` 声明双向一致。之所以只限这一组:Host 包的 `./client` 导出是给浏览器消费方 import 的类型化 wire face,不是 roster 要服务的插件。 +`verify-cordis-config` 要求每个 `packages/client` 包的 `./client` 导出与 `dsh.client` 声明双向一致。缺少这项检查时,陈旧的清单字段可能让组合行与空的 node 半侧保持激活,而浏览器 roster 会悄然漏掉该包。检查只限这一组,因为 Host 包的 `./client` 导出是供浏览器消费方 import 的类型化 wire face,不是 roster 要服务的插件。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index d79eaafd04..3d120f1027 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 24703bd5c98a91bf8708ae243ef7a44df4afb8f1 -2026-08-10-web-session-log-export.zh.md: 9595ea25df927e49b34cde1ba83d6e1720db5095 +2026-08-10-web-session-log-export.md: e64de4ecd564bf585ec857546913828a87ad1279 +2026-08-10-web-session-log-export.zh.md: 2ef377b4bb3c98f1935e67a1619bbcc5ed1789ef diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 24703bd5c9..e64de4ecd5 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -13,7 +13,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. -- The current Header and `/export` consumers are defined by the [Web export command and dialog decision](2026-08-11-web-export-command-and-dialog.md). +- The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 9595ea25df..2ef377b4bb 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -13,7 +13,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 -- 当前 Header 与 `/export` Consumer 由 [Web 导出命令与弹窗决策](2026-08-11-web-export-command-and-dialog.zh.md)定义。 +- 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml deleted file mode 100644 index e403530fec..0000000000 --- a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md -2026-08-11-collapsible-ask-user-question-card.md: 5c7e62749e63a6042285b79751c400ba09038b49 -2026-08-11-collapsible-ask-user-question-card.zh.md: 5f4b5851e4b595e634841bf87827db70fe928afb diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml index f39fbe92b3..4d8cfe3580 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.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/feature/2026-08-11-deepseek-request-user-id-header.md -2026-08-11-deepseek-request-user-id-header.md: 0641c1c78ee9992a77c9c3ea99c9b7377296b3a0 -2026-08-11-deepseek-request-user-id-header.zh.md: 18b84b9debbf598150a6712689c0db078cb99c2f +2026-08-11-deepseek-request-user-id-header.md: 54f59a3f69a933af4f80d629f32f5089676263f6 +2026-08-11-deepseek-request-user-id-header.zh.md: 4904a08fcf4320211fd4253403bddda062bd496e diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md index 0641c1c78e..54f59a3f69 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.md @@ -16,7 +16,7 @@ The user id is transport metadata, not model input. It must not enter the reques The plugin resolves the user id lazily after credentials succeed and memoizes it for that plugin instance. A missing credential therefore does not create `.anonymous-user-id`, while the first authorized provider request can create it even when `DSH_TELEMETRY_DISABLED` is set. The direct adapter constructor accepts a `resolveUserId` dependency so wire behavior remains deterministic in unit tests. -Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. They are absent from the JSON request body and do not become model-visible inputs or session events. A configured gateway receives them. SessionTelemetryBackend sharing controls only telemetry export and does not disable provider request identity. +Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. The identity values are absent from the JSON request body and do not become model-visible inputs or session events. A configured gateway receives them. Provider-specific body extensions are owned separately by the [DeepSeek LLM API extension decision](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md). SessionTelemetryBackend sharing controls only telemetry export and does not disable provider request identity. ## Verification @@ -41,4 +41,4 @@ Both headers are model-hidden HTTP metadata sent to the resolved `baseURL`. They - DeepSeek support can correlate requests across sessions by one anonymous harness-home id and within a conversation by the durable session id. - The first authorized DeepSeek request may create `$DSH_HOME/.anonymous-user-id` independently of telemetry export. - Custom DeepSeek gateways receive the stable user id and any available session id, so operators must treat the configured `baseURL` as an identity recipient. -- The request body, prompt, token count, KV-cache identity, and session log remain unchanged. +- The identity headers do not alter the request body, prompt, token count, KV-cache identity, or session log; separately registered DeepSeek body extensions retain their own contracts. diff --git a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md index 18b84b9deb..4904a08fcf 100644 --- a/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-deepseek-request-user-id-header.zh.md @@ -16,7 +16,7 @@ Status: implemented 插件在凭据解析成功后惰性获取用户 id,并在该插件实例内缓存。缺少凭据不会创建 `.anonymous-user-id`;即使设置了 `DSH_TELEMETRY_DISABLED`,首个已授权的提供方请求仍可能创建它。直连适配器构造函数接收 `resolveUserId` 依赖,使线路行为可在单元测试中保持确定性。 -两个头部都是发送到解析后 `baseURL` 的模型不可见 HTTP 元数据。它们不在 JSON 请求体中,也不会成为模型可见输入或会话事件。配置的网关会收到它们。遥测共享只控制遥测导出,不会禁用提供方请求身份。 +两个头部都是发送到解析后 `baseURL` 的模型不可见 HTTP 元数据。身份值不在 JSON 请求体中,也不会成为模型可见输入或会话事件。配置的网关会收到它们。提供方特定正文扩展由 [DeepSeek LLM API 扩展决策](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md)单独拥有。遥测共享只控制遥测导出,不会禁用提供方请求身份。 ## 验证 @@ -41,4 +41,4 @@ Status: implemented - DeepSeek 支持可以通过一个匿名 harness-home id 跨会话关联请求,并通过持久化 session id 关联同一对话。 - 首个已授权 DeepSeek 请求可独立于遥测导出创建 `$DSH_HOME/.anonymous-user-id`。 - 自定义 DeepSeek 网关会收到稳定用户 id 与可用的会话 id,因此运维方必须将配置的 `baseURL` 视为身份接收方。 -- 请求体、提示词、token 数、KV cache 身份和会话日志保持不变。 +- 身份头部不会改变请求体、提示词、token 数、KV cache 身份或会话日志;单独注册的 DeepSeek 正文扩展保留各自约定。 diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml index 83ed0baf8f..b67c1256e5 100644 --- a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.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/feature/2026-08-11-message-feedback-web-surface.md -2026-08-11-message-feedback-web-surface.md: 762137a6ab179dcbf0b7019cd0ee0163829bd767 -2026-08-11-message-feedback-web-surface.zh.md: 4ed2f61879a2880402300a3150793370d090d2c0 +2026-08-11-message-feedback-web-surface.md: a1fbce695ffa8e513e85842b50f629afde88ac01 +2026-08-11-message-feedback-web-surface.zh.md: 3551cc3b4a1956451886721a4dce712cf038066d diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md index 762137a6ab..a1fbce695f 100644 --- a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md @@ -46,7 +46,7 @@ Toggle semantics keep the two verbs honest: re-clicking the recorded rating call The Web GUI records per-message ratings and notes. #1326's user-visible half now exists; the issue was reopened because the backend merge had closed it while no entry point existed. -`AssistantMessageNode.messageId` is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls today, even though trajectory and waterfall nodes now carry the same id. +`AssistantMessageNode.messageId` is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls, even though trajectory and waterfall nodes now carry the same id. Feedback stays invisible to the model — the sidecar reaches neither the Session log, model context, nor telemetry — so the package's Model Experience is an audited `none` entry rather than a structured block. diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md index 4ed2f61879..3551cc3b4a 100644 --- a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md @@ -46,7 +46,7 @@ list 读取被推迟到首次 hover 或 focus,而不是在 mount 时触发, Web GUI 可以记录逐条消息的评价与备注。#1326 中用户可见的那一半现在存在了;该 Issue 之所以被重开,是因为后端合并在没有任何入口存在的情况下关闭了它。 -`AssistantMessageNode.messageId` 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。今天只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。 +`AssistantMessageNode.messageId` 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。 反馈对模型保持不可见——该 sidecar 既不进入 Session 日志、也不进入模型上下文与 telemetry——因此该包的 Model Experience 是一条经审计的 `none` 条目,而不是结构化区块。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index 5d4de22918..c548bbbcad 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-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/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 2f26dda5ddc905076dbc2f6c681f462973bde793 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 1931a7468029e145d8e4792da1f889f2bc59d6d5 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 7d068aebb6642602aac0a039c8635acf555ccfe8 +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: c32a9a2e09ff0acda592062f780427c636fd65dd diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index 2f26dda5dd..7d068aebb6 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -14,9 +14,9 @@ The two launch paths also have different configuration owners. Web mounts a per- Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. Sandbox and approval services remain mounted and enforce their policies; only their model-facing dynamic context is absent. -The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy. +The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy. -`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. +`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path. ## Verification diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index 1931a74680..c32a9a2e09 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -14,9 +14,9 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` 两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。沙箱与批准服务仍保持挂载并强制其策略;只有它们面向模型的动态上下文缺席。 -独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。 +独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。 -`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 +`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml deleted file mode 100644 index 08b086f3f4..0000000000 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md -2026-08-11-web-export-command-and-dialog.md: d28925500a45111d348b6151df9d4c62cc6de54b -2026-08-11-web-export-command-and-dialog.zh.md: a9a9c4a5cc019a3242561e0cc5f8445625c7cb86 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml index 0720d5d9ee..3c2be099df 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.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/feature/2026-08-12-web-image-intake-and-limits-alignment.md -2026-08-12-web-image-intake-and-limits-alignment.md: 00cf7ea99d63e848c4b5839da1d97d94c9fb8464 -2026-08-12-web-image-intake-and-limits-alignment.zh.md: 7bf7f3621d6d305baf8e7c1c060bbc5810f28b77 +2026-08-12-web-image-intake-and-limits-alignment.md: 0bb8cadc8db4b4c28cf311bc9420c32744e173fb +2026-08-12-web-image-intake-and-limits-alignment.zh.md: fafa7652756554a54a0a0952e843bf5f4b81b00d diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md index 00cf7ea99d..0bb8cadc8d 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md @@ -16,7 +16,7 @@ The second alignment step for issue #2248, after the [attachment display note](2 **History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row. -**Limits aligned and projected.** Defaults are 20 images / 3.5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. Including base64 padding, a 3.5 MiB encoded file occupies at most 4.67 MiB and leaves 0.33 MiB below a 5 MiB route check. Deployments using only routes with larger limits can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. +**Limits aligned and projected.** Intake defaults are 20 images, 32 MiB per source, 100 MiB aggregate source bytes, 100 million decoded pixels, and 16384px per source side. The attachment backend prepares a separate durable master with a 2048px long edge and 4 MiB safety cap. Model requests have their own route-specific pixel and encoded-byte budgets, so source admission does not use provider request limits. The HTTP carrier uses one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` to satisfy the load-time capacity assertion for the 100 MiB aggregate after base64 and envelope expansion. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would require a JSON string near V8's string-size limit. The intake limits reach clients as the `imageLimits` session projection, a constant-per-boot unit registered by **apiproxy** rather than the attachment Service Definition. `dsh-llm` depends on `dsh-attachment`, while `dsh-session-projection` reaches `dsh-llm` through `dsh-session`; registering the projection in the seam package would create a project-reference cycle. The per-message count and aggregate rules are also enforced by the proxy. The `SessionProjectionMap` merge remains in the proxy sessions wire file, which clients already consume through carrier type re-exports. **Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation. diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md index 7bf7f3621d..fafa765275 100644 --- a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md @@ -16,7 +16,7 @@ issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attach **历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。 -**上限对齐并投影。** 默认值为每条消息 20 张、单图 3.5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。3.5 MiB 编码文件包括 base64 填充在内最多占 4.67 MiB,在 5 MiB 路由检查下保留 0.33 MiB 余量。仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 +**上限对齐并投影。** 输入默认值是每条消息 20 张、每个源文件 32 MiB、源文件总量 100 MiB、每张图片一亿解码像素,以及源文件任一边 16384px。附件后端另行生成长边 2048px、独立安全上限 4 MiB 的持久主版本。模型请求使用各路由自己的像素和编码字节预算,因此源文件准入不采用提供方请求限制。HTTP 载体统一使用 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`,满足 100 MiB 总量经过 base64 和请求封装扩张后的加载时容量断言。512 MiB 总量无法通过当前传输,因为 base64 进入 JSON 后会需要一个接近 V8 字符串大小上限的 JSON 字符串。输入上限通过 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元,由 **apiproxy** 而非 attachment Service Definition 注册。`dsh-llm` 依赖 `dsh-attachment`,而 `dsh-session-projection` 经 `dsh-session` 到达 `dsh-llm`;在 seam 包注册投影会形成 project-reference 环。每条消息的数量和总量规则也由 proxy 强制执行。`SessionProjectionMap` 合并继续放在 proxy 的 sessions 协议文件中,客户端已经通过载体类型再导出使用它。 **加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml index 84b070138b..fda5e932c9 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.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/feature/2026-08-13-shared-modal-product-onboarding.md -2026-08-13-shared-modal-product-onboarding.md: 771b6675ea170c5001fca7c880f6c246e19fe7f1 -2026-08-13-shared-modal-product-onboarding.zh.md: a46bc273e0c2fd24cc45efd5e93d3aa24b3102bd +2026-08-13-shared-modal-product-onboarding.md: ec3d3e5f112b04736a15645c6e62e942367bb4fa +2026-08-13-shared-modal-product-onboarding.zh.md: 78b17d02dba52170722042126cc5616e9272c722 diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md index 771b6675ea..ec3d3e5f11 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.md @@ -30,4 +30,4 @@ First-run onboarding mixed two interaction models: a viewport takeover for produ ## Consequences -A fresh loopback profile sees the specified internal-testing notice, then an inline DeepSeek key dialog only when no provider is usable. Acknowledgement remains versioned in `settings.yaml`, secrets remain write-only in `.credentials.yaml`, and already-ready or unsupported deployments render no onboarding chrome while readiness loads. The Models package now owns product-onboarding presentation as well as provider configuration; its README and browser coverage make that broader responsibility explicit. This decision restores a concise testing-stage notice after the historical [full-viewport beta notice removal](../simplification/2026-08-13-remove-first-run-beta-notice.md) without restoring that notice's telemetry copy or takeover layout. +A fresh loopback profile sees the specified internal-testing notice, then an inline DeepSeek key dialog only when no provider is usable. Acknowledgement remains versioned in `settings.yaml`, secrets remain write-only in `.credentials.yaml`, and already-ready or unsupported deployments render no onboarding chrome while readiness loads. The Models package now owns product-onboarding presentation as well as provider configuration; its README and browser coverage make that broader responsibility explicit. This decision restores a concise testing-stage notice after the historical [full-viewport beta notice removal](../../archived/simplification/2026-08-13-remove-first-run-beta-notice.md) without restoring that notice's telemetry copy or takeover layout. diff --git a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md index a46bc273e0..78b17d02db 100644 --- a/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-13-shared-modal-product-onboarding.zh.md @@ -30,4 +30,4 @@ Status: implemented ## 后果 -新的回环 profile 会先看到指定的内测声明;仅当没有任何可用提供方时,之后才会出现行内 DeepSeek 密钥弹窗。确认仍按版本写入 `settings.yaml`,secret 仍以只写方式存入 `.credentials.yaml`,已就绪或无法修复的部署在加载判定期间不会渲染任何引导框架。Models 包现在同时持有产品引导展示与提供方配置;README 和浏览器覆盖明确记录了这项扩展后的职责。本决策在历史上的[全屏内测声明移除](../simplification/2026-08-13-remove-first-run-beta-notice.zh.md)之后恢复简洁的测试阶段声明,但不会恢复那份声明中的遥测文案或接管式布局。 +新的回环 profile 会先看到指定的内测声明;仅当没有任何可用提供方时,之后才会出现行内 DeepSeek 密钥弹窗。确认仍按版本写入 `settings.yaml`,secret 仍以只写方式存入 `.credentials.yaml`,已就绪或无法修复的部署在加载判定期间不会渲染任何引导框架。Models 包现在同时持有产品引导展示与提供方配置;README 和浏览器覆盖明确记录了这项扩展后的职责。本决策在历史上的[全屏内测声明移除](../../archived/simplification/2026-08-13-remove-first-run-beta-notice.md)之后恢复简洁的测试阶段声明,但不会恢复那份声明中的遥测文案或接管式布局。 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml index 9a9f1614fd..8d482bf05e 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.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/feature/2026-08-18-pi-ai-wire-compat-surface.md -2026-08-18-pi-ai-wire-compat-surface.md: 3da2db1ebdf67bcfaf8c872491356b0ef7d0ca89 -2026-08-18-pi-ai-wire-compat-surface.zh.md: ff9870f5863fb96ee026dfab1b96b4e1f3e6e238 +2026-08-18-pi-ai-wire-compat-surface.md: 280ce2dd16bb7f89f238c8e0fab07ed74de943f1 +2026-08-18-pi-ai-wire-compat-surface.zh.md: 1a5704dc8c462a8007d1129de4ec7a51e9bd22a4 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md index 3da2db1ebd..280ce2dd16 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -14,13 +14,13 @@ Writing the field anyway was worse than unsupported. schemastery passes unknown ## Decision -One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty distinct fields, twenty offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. +One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty-four distinct fields, twenty-three offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. `PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, and `exactOptionalPropertyTypes` is what makes that annotation load-bearing in both directions, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation. Field *types* are derived from upstream rather than restated, and a second proof pins the profile assignable to the upstream compat types, so a widened value union cannot silently narrow what configuration accepts — the cast to `ModelCompat` at materialization would otherwise hide it. -Protocol applicability is per field, and grouping follows the compat *type* rather than the protocol name: pi-ai gives `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` one `OpenAIResponsesCompat`, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from `Model.compat`'s own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable; nothing cross-checks that pairing, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read. +Protocol applicability is per field, and grouping follows the compat *type* rather than the protocol name: pi-ai gives `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` one `OpenAIResponsesCompat`, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from `Model.compat`'s own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` and `chatTemplateArgs` are offered, which makes the two `chat-template` formats and `baseten` nameable; nothing cross-checks those pairings, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read. -Three kinds of `compat` key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's `??` reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. +Three kinds of `compat` key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's `??` reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes absent dicts as `{}` and both template-argument fields are present on every parsed profile whether or not anyone wrote them. ## Where a refusal lands @@ -45,7 +45,7 @@ An external edit to the settings file is the one path that cannot report: the pr ## Consequences - An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. -- A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. +- A pi-ai upgrade that adds a compat field or thinking format fails the build until someone classifies it; the gates include both template-argument fields and every current format. - Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. - **Deferred, not closed:** a route that repoints `api` and configures no compat at all keeps the installed entry's `compat` through the model literal's `...base` spread, in the *other* protocol's shape. Fields several compat types share (`supportsLongCacheRetention`, `sendSessionAffinityHeaders`) therefore cross protocols. It predates this surface — the early return it rides existed before — and is left for its own change. - **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md index ff9870f586..1a5704dc8c 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -14,13 +14,13 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 ## Decision -每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 +每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十四个字段,开放二十三个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 `PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,而使这条标注在两个方向上都真正吃劲的是 `exactOptionalPropertyTypes`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的**类型**派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 `ModelCompat` 的强转会把它洗掉。 -协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 +协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 与 `chatTemplateArgs` 予以开放,因此两个 `chat-template` 格式和 `baseten` 都可命名;这些配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 -三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 +三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,两个模板参数字段都会出现在每一个解析过的 profile 上。 ## Where a refusal lands @@ -45,7 +45,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 ## Consequences - 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 -- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- pi-ai 升级新增 compat 字段或思考格式会使构建失败,直到有人为它做出分类;门禁包含两个模板参数字段和当前每一种格式。 - 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 - **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。 - **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml deleted file mode 100644 index 419ad47b8f..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md -2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04 -2026-08-19-direct-deepseek-vision-input.zh.md: 3e0fc26ee8ba8d0bed1478d54add5be1bfef01df diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md deleted file mode 100644 index 5c2f2ea3ff..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: Direct DeepSeek vision input - -Status: implemented - -English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) - -## Problem - -DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. - -## Decision - -The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently. - -The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. - -The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. - -Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. - -## Alternatives considered - -- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. -- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. -- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. -- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. - -## Verification - -Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. - -## Consequences - -Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md deleted file mode 100644 index 3e0fc26ee8..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: 直接 DeepSeek 视觉输入 - -Status: implemented - -[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 - -## Problem - -DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 - -## Decision - -直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。 - -适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 - -直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.zh.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 - -规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 - -## Alternatives considered - -- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 -- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 -- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 -- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 - -## Verification - -包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。 - -## Consequences - -已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml deleted file mode 100644 index b8c987747c..0000000000 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md -2026-08-19-high-cache-hit-decimal-display.md: 952d838fdf330175915e13ef767fd80d145506b2 -2026-08-19-high-cache-hit-decimal-display.zh.md: ba33bd8a265211c8514fda4babda5ba94ed02884 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml new file mode 100644 index 0000000000..6a379a3532 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.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/implemented/feature/2026-08-20-unified-image-request-pipeline.md +2026-08-20-unified-image-request-pipeline.md: ada15d540539977c631e359ffdc7baa4fa84c78e +2026-08-20-unified-image-request-pipeline.zh.md: 85c9a1f837d82cba2bc62b30402433f50c873cbe diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md new file mode 100644 index 0000000000..ada15d5405 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -0,0 +1,69 @@ +# Agent Note: Unified normalized attachments, request versions, and provider files + +Status: implemented + +English | [中文](2026-08-20-unified-image-request-pipeline.zh.md) + +## Problem + +Durable image history, provider resolution, inline request size, and remote file reuse have different limits. Treating an admitted image as the bytes sent on every later request forced one byte cap and one raster to serve all four concerns. Large but ordinary input was refused, clean 16-bit PNG could pass into history and fail at DeepSeek, repeated base64 expanded long requests, and a provider rejection repeated because the same durable image stayed in every future request. + +## Decision + +The image path has two explicit versions. The attachment backend owns a provider-independent durable normalized attachment. Each image-capable model route owns a deterministic request policy, and the attachment backend derives and caches the exact request version from that attachment. Session history contains only the normalized attachment reference; inline bytes and provider file ids remain transient request projections. + +### Provider-independent normalized attachment + +Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization. + +The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference. + +Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule. + +### Deterministic request versions + +`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default; low detail uses 512 by 512 total pixels. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams. + +The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared. + +Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. A text-only route receives deterministic attachment placeholders, including nested tool-result images, while append-only session history keeps the original references. + +### Stable handles + +Every retained request image is preceded by its complete attachment id and actual request dimensions. User messages, tool results, agent-loop requests, compaction, and direct `ctx.llm.stream` calls share this projection. + +### DeepSeek Files lifecycle + +The direct `deepseek-official` adapter normally uploads every retained request version through the OpenAI-compatible Files API and sends `file_id` content blocks. A [bounded inline fallback](../bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by endpoint and API-key scope plus `variantId`. Uploads request seven days by default and record the returned `expires_at`; a mapping with no more than one hour remaining is replaced without a preceding retrieve call. The index never stores the API key. + +An upload is indexed only after the response returns a complete file object, matching byte count, and `expires_at`. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If chat reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that chat attempt. The affected request bytes are uploaded again and chat is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third chat attempt. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces the documented 128MiB upload limit, 32MiB chat-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. + +### Diagnostics + +A 16-bit RGB or RGBA PNG is normal admitted input and converts to 8-bit sRGB/sRGBA. If local conversion fails, `read_image` names the path, detected 16-bit PNG, required normalized form, and manual conversion remedy. If DeepSeek rejects a normalized request version, the primary error names the attachment or display name, durable message and image position, normalized media type, 8-bit sRGB/sRGBA depth, dimensions, and provider message. An ambiguous multi-image rejection lists every candidate. The raw provider body remains the error cause rather than the only visible message. + +Historical attachment objects that later disappear or fail integrity verification remain fail-loud. Durable quarantine and verified recovery require session events and are tracked by [Quarantine unreadable historical attachments](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md). + +## Alternatives considered + +**Use one 1MiB normalized attachment for storage and requests.** This makes model resolution determine durable image detail and combines local storage, inline expansion, Files quota, and model pixels into one setting. Independent normalization and request policies keep those responsibilities explicit. + +**Reject images above provider dimensions or at the encoding quality floor.** A provider limit is route-specific and future requests may use another model. Proportional normalization and request projection accept ordinary large images while bounding each later representation. + +**Treat PNG as a screenshot and reject 16-bit PNG.** File format does not reveal pixel complexity, and 16-bit RGB/RGBA is a convertible sample depth rather than an unsupported image type. Pixel sampling and post-conversion probes give the required facts. + +**Keep DeepSeek data URLs as the primary transport.** Inline base64 repeats bytes on every request and caps usable image history by request-body size. Files API references reuse uploaded deterministic request bytes and provide explicit expiry and deletion; the bounded fallback uses data URLs only when file resolution fails. + +**Trust a locally indexed file id indefinitely.** Remote expiry, deletion, and lost upload responses make local and provider state diverge. Response-directed invalidation and one re-upload recover without an unbounded retry loop; an ambiguous stale-file response must invalidate every file used by that attempt because it provides no safe exact target. + +**Refuse text-only model selection after any image.** Durable history can outlive the model that first consumed it. Request-local placeholders keep the session usable without rewriting history. + +**Remove one image whenever a request crosses its limit.** That changes an early request message after nearly every new upload. Quantized removed prefixes keep cache invalidation occasional while honoring the configured high bound. + +## Verification + +Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry. + +## Consequences + +Normalized attachments consume up to the independent local safety cap, while request caches and remote Files consume additional derived storage. Deterministic identities and singleflight make that work reusable across turns and sessions sharing the same DSH home. Two simultaneous transforms reduce batch latency while increasing peak RSS relative to serial execution; deployments with tighter memory can set the limit to one. Encoder or transform-version changes create new future identities without rewriting existing history. DeepSeek image requests prefer Files reuse; bounded stale-id recovery handles inconsistent remote state, while file-resolution failures use the smaller inline budget. Missing or corrupt durable attachments still require the separate quarantine design. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md new file mode 100644 index 0000000000..85c9a1f837 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -0,0 +1,69 @@ +# Agent Note: 统一规范化附件、请求版本与提供方文件 + +Status: implemented + +[English](2026-08-20-unified-image-request-pipeline.md) | 中文 + +## Problem + +持久图片历史、提供方分辨率、内联请求大小和远端文件复用有不同限制。过去把已接纳图片直接作为之后每次请求发送的字节,导致一个字节上限和一份光栅同时承担四种职责。普通大图会被拒绝;干净的 16-bit PNG 可以进入历史,之后才被 DeepSeek 拒绝;重复 base64 使长会话请求持续增长;提供方拒绝后,同一持久图片还会进入每次后续请求。 + +## Decision + +图片路径有两个显式版本。附件后端拥有提供方无关的持久规范化附件。每条支持图片的模型路由拥有确定性请求策略,附件后端从该附件派生并缓存确切请求版本。会话历史只包含规范化附件引用;内联字节和提供方文件 ID 都是瞬时请求投影。 + +### 提供方无关的规范化附件 + +每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。 + +规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。 + +批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。 + +### 确定性请求版本 + +`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB;low detail 使用总像素 512×512。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。 + +`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。 + +请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。纯文本路由会收到确定性的附件占位文本,其中包括嵌套工具结果图片;追加式会话历史继续保留原始引用。 + +### 稳定句柄 + +每张保留请求图片前都有完整附件 ID 和实际请求尺寸。用户消息、工具结果、agent loop 请求、压缩和直接 `ctx.llm.stream` 调用共享这套投影。 + +### DeepSeek Files 生命周期 + +直接 `deepseek-official` 适配器通常通过 OpenAI 兼容 Files API 上传每张保留的请求版本,并发送 `file_id` 内容块。文件解析失败时,[有界内联回退](../bug-fix/2026-08-21-deepseek-files-inline-fallback.zh.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期,并记录返回的 `expires_at`;本地映射剩余时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 + +只有上传响应返回完整文件对象、匹配的字节数和 `expires_at` 时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果 chat 报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次 chat 使用的全部映射。受影响的请求字节会重新上传,chat 只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次 chat。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行文档规定的 Files 单次上传 128MiB、chat 单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 + +### 诊断 + +16-bit RGB 或 RGBA PNG 属于普通可接纳输入,会转换为 8-bit sRGB/sRGBA。本地转换失败时,`read_image` 会写明路径、检测到的 16-bit PNG、所需规范形式和手工转换方法。如果 DeepSeek 拒绝已规范化请求版本,主错误会写明附件 ID 或显示名称、持久消息和图片位置、规范化媒体类型、8-bit sRGB/sRGBA 位深、尺寸和提供方消息。多图片错误无法确定对象时会列出全部候选图片。原始提供方正文保留为错误 cause,不会成为唯一可见消息。 + +持久附件对象之后缺失或无法通过完整性校验时,系统仍会明确失败。持久隔离和经校验恢复需要新增会话事件,由[隔离不可读历史附件](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)继续跟踪。 + +## Alternatives considered + +**使用一份 1MiB 规范化附件同时负责存储和请求。** 这种做法让模型分辨率决定持久图片细节,并把本地存储、内联膨胀、Files 配额和模型像素合并成一个设置。独立的规范化和请求策略会明确区分这些职责。 + +**拒绝超过提供方尺寸或达到编码质量下限的图片。** 提供方限制属于具体路由,未来请求可能改用另一个模型。按比例规范化和投影请求版本可以接纳普通大图,同时约束每种后续表示。 + +**把 PNG 当作截图,并拒绝 16-bit PNG。** 文件格式不能说明像素复杂度,16-bit RGB/RGBA 是可转换位深,不是不支持的图片类型。像素采样和转换后探测能提供所需事实。 + +**把 DeepSeek data URL 作为首选传输方式。** 内联 base64 会在每次请求中重复字节,并按请求正文大小限制可用图片历史。Files API 引用会复用上传后的确定性请求字节,并提供显式有效期和删除操作;有界回退只在文件解析失败时使用 data URL。 + +**永久信任本地索引中的文件 ID。** 远端过期、删除和上传响应丢失会使本地与提供方状态不一致。按响应失效和一次重新上传可以恢复,同时避免无界重试;响应没有给出可安全使用的精确目标时,必须使该次请求使用的全部文件失效。 + +**历史中出现图片后拒绝选择纯文本模型。** 持久历史可能比最初读取它的模型存活更久。按请求生成的占位文本可以保持会话可用,无需改写历史。 + +**请求每次越过上限就移除一张图片。** 这种做法会在几乎每次新增图片后改写较早的请求消息。按固定步长递增的移除前缀会降低缓存失效频率,同时遵守配置的上限。 + +## Verification + +包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。 + +## Consequences + +持久规范化附件最多占用独立的本地安全上限,请求缓存和远端 Files 还会占用额外派生存储。确定性身份和 singleflight 使这些成本可以被共享同一 DSH home 的轮次和会话复用。同时执行两个变换会降低批次延迟,但峰值 RSS 高于串行执行;内存更紧张的部署可以把上限设为 1。编码器或变换策略版本变化会为未来内容产生新身份,不会改写已有历史。DeepSeek 图片请求优先复用 Files;有界的陈旧 ID 恢复会处理远端状态不一致,文件解析失败则使用较小的内联预算。缺失或损坏的持久附件仍需要单独的隔离设计。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml index f19a710287..03918f3585 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md -2026-08-17-blank-permission-default-refresh.md: 6e54d030eed4523974c9ca55ff8a621e107704a9 -2026-08-17-blank-permission-default-refresh.zh.md: 171395ade710082029f874da127b8934406ce6ac +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md +2026-08-22-fire-and-forget-webhook-sessions.md: 976bccd8b460de7cb696ee45ea8963710cc4c738 +2026-08-22-fire-and-forget-webhook-sessions.zh.md: f015d993e61864bbc21d9d55fc331c25118fbde3 diff --git a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md new file mode 100644 index 0000000000..976bccd8b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md @@ -0,0 +1,58 @@ +# Agent Note: Fire-and-forget webhook Sessions + +Status: implemented + +English | [中文](2026-08-22-fire-and-forget-webhook-sessions.zh.md) + +## Problem + +External repository events need to start ordinary DSH work without making every provider adapter understand Agent presets, Workspace attachment, titles, permissions, and callback teardown. GitHub pull requests becoming ready for review are the first use: a signed event may create a review Session that users can browse under the repository Workspace. + +Turning this into a durable automation engine would introduce a second lifecycle beside Sessions: delivery records, execution states, retry and deduplication policy, crash recovery, and an answer to whether HTTP acceptance, prompt admission, Agent idle, or model output means completion. The requested capability needs none of those meanings. + +## Decision + +`@deepseek-ai/dsh-webhook` owns a two-operation Host runtime: rules register through `register()`, and authenticated provider adapters call `dispatch()`. Each matching callback runs independently as arbitrary trusted code and returns `null` or one Workspace-backed Session request. Dispatch returns before callbacks settle, while effect disposal aborts and drains only the calls it owns. + +The runtime stores no provider delivery or execution record. It does not retry, deduplicate, resume callback work, observe Agent status, or collect a result. A repeated delivery may create another Session. `WebhookDeliveryId` remains available to a rule that deliberately implements idempotency through its own state. + +## Provider adapters + +Authentication belongs to provider adapters. `@deepseek-ai/dsh-webhook-github` registers one exact route on an injected WebServer, bounds the untouched UTF-8 body, resolves its secret reference per request, verifies `X-Hub-Signature-256` before parsing, and passes a signed lossless-JSON object to the runtime. `202` means only verified in-memory dispatch; it precedes rule matching, external calls, and Session creation. + +The normal Web composition keeps its UI/API WebServer separate. The GitHub example mounts another WebServer and its adapter in a group that isolates only `webServer`, so a reverse proxy can expose the webhook port without exposing `/api`, WebSockets, or frontend files. + +Patch loading anchors relative plugin names in inserted rows to the patch file. The same `./github-ready-review-rule.mjs` entry therefore works from a development `--patch` overlay and from a permanent profile patch without changing the rule into a package. + +## Session creation + +A rule result names a local Workspace path, title, text prompt, agent preset, permission preset, and optional explicit provider/model route with an output cap. Without that route, the runtime snapshots the complete live default, including reasoning effort, until the first request records its durable header. It validates presets before mutation, resolves or creates the canonical Workspace, creates the Agent with that path as Session cwd, mounts the preset before publication, and attaches the Session before admitting the prompt. + +The initial follow-up is an ordinary durable user-role message with webhook provider, source, delivery, and rule provenance. Its inbox insertion is the webhook operation's last boundary. Ordinary Session persistence and Agent lifecycle own later work; the runtime neither flushes specially nor waits for a turn. + +## Alternatives considered + +**Persist deliveries and execution states.** Rejected because `pending`, `admitted`, `running`, and `settled` require retry, deduplication, crash, and completion semantics that the current capability does not consume. + +**Acknowledge GitHub after Session creation.** Rejected because arbitrary rules may call external systems and exceed the provider's HTTP window; a valid delivery should not couple transport availability to later rule work. + +**Register the route on the main WebServer.** Rejected because operators need to expose webhook ingress without also exposing the browser API. An isolated second instance reuses the existing HTTP module without creating another server implementation. + +**Restrict rules to a declarative predicate language.** Rejected because programmatic rules explicitly need arbitrary external calls. Trusted Cordis plugins already provide the required authority and lifecycle. + +**Let each adapter create Sessions directly.** Rejected because Workspace, preset, permission, title, rollback, and provenance logic would spread across provider packages. + +## Verification + +Package tests pin independent callback execution, fire-and-forget HTTP timing, cancellation and quiescent disposal, request validation, Workspace attachment before prompt admission, rollback, GitHub HMAC and body limits, credential rotation, and exact Loader composition. The assembled Web example sends a signed ready-for-review delivery to an isolated second listener and records the resulting ordinary Workspace conversation. + +A real-API e2e test starts the built `dsh web` CLI with the webhook overlay and isolated listener, synthesizes only the signed inbound GitHub delivery, observes Workspace attachment and durable provenance through the public Web API, and waits for the real DeepSeek response. No DSH service, model adapter, or provider call is replaced by a test double. + +Source audits keep execution records, retry timers, dedupe maps, completion events, and Agent-status listeners absent. + +## Consequences + +- Provider adapters stay small and provider-specific while Session creation has one owner. +- Users receive ordinary titled Sessions under Web Workspaces rather than a second automation UI. +- HTTP success intentionally says nothing about downstream matching or Agent success. +- Crashes and repeated deliveries retain simple at-most-process-lifetime semantics; deployments needing durable automation must add a separately designed subsystem rather than reinterpret this runtime. diff --git a/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md new file mode 100644 index 0000000000..f015d993e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.zh.md @@ -0,0 +1,58 @@ +# Agent Note: Fire-and-forget webhook Session + +Status: implemented + +[English](2026-08-22-fire-and-forget-webhook-sessions.md) | 中文 + +## Problem + +外部仓库事件需要启动普通 DSH 工作,同时不能让每个提供方适配器都理解 Agent preset、Workspace 附加、标题、权限与回调 teardown。GitHub pull request 变为 ready for review 是第一个用途:签名事件可以创建一个评审 Session,用户能在仓库 Workspace 下浏览它。 + +如果把它变成持久自动化引擎,就会在 Session 旁引入第二套生命周期:交付记录、执行状态、重试与去重策略、崩溃恢复,以及 HTTP 接受、提示词接纳、Agent idle 或模型输出中究竟哪个表示完成。所请求能力不需要其中任何含义。 + +## Decision + +`@deepseek-ai/dsh-webhook` 拥有只有两个操作的 Host runtime:规则通过 `register()` 注册,已验证身份的提供方适配器调用 `dispatch()`。每个匹配回调都作为任意受信任代码独立运行,并返回 `null` 或一个基于 Workspace 的 Session 请求。dispatch 会在回调结算前返回,而 effect disposer 只中止并排空自己拥有的调用。 + +runtime 不存储提供方交付或执行记录。它不重试、不去重、不恢复回调工作、不观察 Agent 状态,也不收集结果。重复交付可能创建另一个 Session。`WebhookDeliveryId` 仍可供有意通过自有状态实现幂等性的规则使用。 + +## Provider adapters + +身份验证属于提供方适配器。`@deepseek-ai/dsh-webhook-github` 会在注入的 WebServer 上注册一条精确路由,限制未改动的 UTF-8 body,为每次请求解析密钥引用,在解析前验证 `X-Hub-Signature-256`,并把签名无损 JSON 对象交给 runtime。`202` 只表示已验证的内存分发;它先于规则匹配、外部调用和 Session 创建。 + +普通 Web 组合保持其 UI/API WebServer 独立。GitHub 示例会把另一个 WebServer 及其适配器挂载到只隔离 `webServer` 的 group 中,因此反向代理可以暴露 webhook 端口,而不暴露 `/api`、WebSocket 或前端文件。 + +Patch 加载会把插入行中的相对插件名锚定到 patch 文件。因而同一个 `./github-ready-review-rule.mjs` 条目既可用于开发环境的 `--patch` overlay,也可用于永久 profile patch,而无需把规则改成软件包。 + +## Session creation + +规则结果会指定本地 Workspace 路径、标题、文本提示词、agent preset、permission preset,以及可选的明确提供方/模型路由与输出上限。没有明确路由时,runtime 会快照包含推理强度的完整实时默认选择,直到首个请求记录其持久 header。runtime 会在变更状态前验证 preset,解析或创建规范 Workspace,以该路径作为 Session cwd 创建 Agent,在发布前挂载 preset,并在接纳提示词前附加 Session。 + +初始 follow-up 是普通持久 user-role 消息,并携带 webhook 提供方、来源、交付和规则来源信息。它的 inbox 插入是 webhook 操作的最后边界。之后的工作由普通 Session persistence 与 Agent 生命周期拥有;runtime 既不执行特殊 flush,也不等待轮次。 + +## Alternatives considered + +**持久化交付与执行状态。** 否决,因为 `pending`、`admitted`、`running` 与 `settled` 需要当前能力没有消费方的重试、去重、崩溃和完成语义。 + +**在 Session 创建后再向 GitHub 确认。** 否决,因为任意规则可能调用外部系统并超过提供方 HTTP 时间窗;有效交付不应把传输可用性与后续规则工作耦合。 + +**在主 WebServer 上注册路由。** 否决,因为操作者需要暴露 webhook 入口而不同时暴露浏览器 API。隔离的第二个实例会复用现有 HTTP 模块,而不会创建另一套服务器实现。 + +**把规则限制为声明式谓词语言。** 否决,因为程序化规则明确需要任意外部调用。受信任 Cordis 插件已经提供所需权限与生命周期。 + +**让每个适配器直接创建 Session。** 否决,因为 Workspace、preset、权限、标题、rollback 与来源信息逻辑会散布到各提供方包。 + +## Verification + +包级测试固定独立回调执行、fire-and-forget HTTP 时序、取消与静止态释放、请求验证、提示词接纳前的 Workspace 附加、rollback、GitHub HMAC 与 body 限制、凭据轮换和精确 Loader 组合。组装 Web 示例会向隔离的第二监听器发送签名 ready-for-review 交付,并记录所得普通 Workspace 对话。 + +真实 API e2e 测试会通过带 webhook overlay 与隔离监听器的构建产物启动 `dsh web` CLI(命令行界面),只合成带签名的入站 GitHub 交付,通过公开 Web API 观察 Workspace 附加与持久来源信息,并等待真实 DeepSeek 响应。测试不会用 test double 替换任何 DSH 服务、模型适配器或提供方调用。 + +源码审计会保持执行记录、重试 timer、去重 map、完成事件与 Agent 状态监听器不存在。 + +## Consequences + +- 提供方适配器保持小而且只含提供方逻辑,Session 创建只有一个 owner。 +- 用户在 Web Workspace 下获得普通带标题 Session,而不是第二套自动化 UI。 +- HTTP 成功刻意不说明下游匹配或 Agent 成功。 +- 崩溃与重复交付保持简单的进程生命周期内语义;需要持久自动化的部署必须增加单独设计的子系统,而不是重新解释此 runtime。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml rename to .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml index 3a62ad16dc..f411b9da70 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md -2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698 -2026-08-06-plan-narrow-viewport-regression.zh.md: 56b305645484060693dbd38397228676be73645c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md +2026-08-22-standard-acp-automation-controls.md: 0ab03eac8b99267da5bd26bf2c86bfadca2a4956 +2026-08-22-standard-acp-automation-controls.zh.md: 2e1348a4f2791e90492fc1402c96eaf29abb00a4 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md new file mode 100644 index 0000000000..0ab03eac8b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md @@ -0,0 +1,79 @@ +# Agent Note: Standard ACP v1 automation controls + +Status: implemented + +English | [中文](2026-08-22-standard-acp-automation-controls.zh.md) + +> This note supersedes only the prompt-only protocol inventory in [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). That decision's prohibition on ACP becoming a second product UI remains authoritative. + +## Problem + +The automation-only ACP bridge could create a fresh session, submit one prompt at a time, cancel it, receive committed assistant messages, and answer one-shot permission requests. A generic external automation controller still needed private process knowledge to discover models, attach MCP servers, find durable sessions after restart, resume them, close one session independently, and observe reasoning, tool, or context-pressure progress. Reproducing those controls in an integration-specific runtime would make ACP nominally interoperable while leaving DSH automation dependent on a private side protocol. + +The stable ACP v1 protocol already defines the required control vocabulary. Adding private `_meta`, custom methods, use-case-specific environment handling, or presentation projections would fragment that vocabulary and revive the UI coupling removed by the automation-only decision. + +## Decision + +`@deepseek-ai/dsh-acp` implements the complete standard ACP v1 automation subset needed by a generic controller: `session/new`, `session/list`, `session/resume`, `session/close`, `session/prompt`, `session/cancel`, `session/set_config_option`, JSON-RPC `$/cancel_request`, `session/update`, and `session/request_permission`. It uses `@agentclientprotocol/sdk` 1.4's app/context interface on both sides of every in-repository connection. + +Capabilities omit unsupported methods and features. DSH adds no custom method, capability flag, or `_meta`, and assigns no private meaning to client metadata. `session/load`, `session/delete`, `session/fork`, additional directories, SSE and ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation remain unsupported. Session controls and semantic updates are protocol data for automation; they do not make ACP a human UI. + +## Per-session ownership + +One `AcpSession` module owns each published Agent handle, selected model state, request MCP mounts, single prompt slot, ordered update chain, and memoized close operation. Global event listeners only identify the exact Agent or Session and delegate to that module. The module associates the admission snapshot with the identified message in memory until inbox claim, then pins it to the admitted turn. Image capability checks, prompt variables, request headers, and every model step therefore use one provider/model/reasoning tuple, while the ordinary durable user source remains unchanged. A concurrent configuration change affects the next ACP turn. + +Explicit `session/close`, connection loss, and plugin disposal call the same close operation. It cancels admission and Agent work before waiting, drains committed updates and continuable descendants, flushes persistence, and releases the Agent scope and its MCP clients. Close retains event routing until the drain completes. Failure reporting waits for all owned session teardowns, and other frontends' Agents and descendants remain untouched. + +## Persistent session controls + +Complete ACP lifecycle support requires session persistence. `session/list` reads materialized top-level headers, excludes active and descendant sessions, filters by canonical physical `cwd`, sorts by creation time and id, and returns bounded pages using opaque keyset cursors. Summaries deliberately omit titles and presentation metadata. + +`session/new` explicitly asks persistence to materialize the live session header without inventing a session event, so even an empty session can be closed, listed, and resumed. Other frontends retain the persistence seam's lazy default and leave abandoned empty sessions unmaterialized. `session/resume` rejects active ids and non-top-level or unknown persisted ids, verifies the requested canonical `cwd` before Agent composition, restores the durable session without replaying it to the client, and mounts the MCP declarations supplied by that request. `session/close` leaves the durable log available for a later process. + +Persistence deliberately treats `create(meta)` as a live registration: JSONL creates no artifact and SQLite creates no row until the first event append. That default removes abandoned empty sessions, but ACP cannot inherit it because `session/new` publishes a session identity before any prompt and the process may stop after the success response without receiving `session/close`. The bridge materializes only after Agent and MCP composition succeeds and before returning `session/new`; failed composition remains residue-free, while every returned id survives restart. + +`ensureMaterialized(session)` accepts the exact live Session so the coordinator first flushes it, then serializes header-only materialization on the existing per-session write chain using the immutable registered header. JSONL writes one header frame and SQLite writes one metadata row; repeat calls are idempotent, and an unsupported backend fails session creation instead of promising resumability it cannot provide. Making `create` eager would change every frontend's abandoned-session behavior, appending a synthetic event would invent a sequence and replay fact solely to trigger storage, and waiting until close would make durability race process loss. + +## Standard configuration options + +The advisory LLM catalog now serves another automation consumer without becoming request validation. ACP exposes a provider-grouped `model` select whose opaque values retain the provider/model pair, plus a dependent `reasoning_effort` select from the resolved exact model. A model with efforts but no adapter-configured default includes `Provider default`, which preserves omission and lets the provider choose. New, resume, and set responses return the complete state. Adapter topology events emit `config_option_update`; per-session mutations serialize in receive order. The configured ACP provider/model remains the initial selection, and unlisted configured routes are synthesized into the returned choices instead of being rejected. + +## Standard MCP mapping + +`session/new` and `session/resume` accept standard stdio and Streamable HTTP MCP declarations. Stdio uses the session `cwd`; HTTP uses the declared URL and headers; both retain `dsh-mcp-client` timeout and reconnect defaults. Names, commands, URLs, environment entries, headers, and duplicate normalized namespaces are validated before Agent publication. Initial connection or discovery failure rolls the unpublished Agent back. + +MCP namespace reservations follow the nearest DSH registration scope rather than the process root. Independent Agent scopes may use the same server name, while duplicate names inside one Agent still fail. Scoped disposal releases tools, transports, and reservations. + +ACP clients are trusted controllers: a stdio declaration authorizes process execution and an HTTP declaration authorizes requests with its headers. DSH does not add per-server private cwd or timeout fields. Ordinary DSH tool policy still governs calls after tools are mounted. + +## Semantic update projection + +Only committed durable facts reach `session/update`. Assistant text/images become `agent_message_chunk`; reasoning becomes `agent_thought_chunk`; tool calls/results become generic `tool_call` and `tool_call_update`; known measured context pressure and capacity become `usage_update`; adapter topology changes become `config_option_update`. Durable message ids and tool-call ids preserve correlation. The canonical DSH tool name is the standard tool-call title. + +The per-session chain serializes all updates and drains before prompt completion. A tool-call notification drains before a permission request refers to it. Raw model deltas, retry attempts, cards, terminal state, diffs, locations, plans, titles, todos, and unsupported content stay off the wire. + +`session/cancel` and `$/cancel_request` enter the same prompt-owned cancellation path. Correlated endings map only to standard stop reasons and JSON-RPC errors; a model output limit reports `max_tokens`. ACP returns no additional DSH result structure. + +## Alternatives considered + +**Add a private controller extension.** Rejected because standard ACP v1 already carries the required lifecycle, configuration, MCP, cancellation, permission, and semantic-update concepts. A private extension would make generic SDK clients incomplete. + +**Restore the former editor projection.** Rejected because plans, terminals, diffs, cards, navigation, and human elicitation are presentation responsibilities. Semantic tool and reasoning facts are useful automation telemetry without importing presentation modules. + +**Implement every ACP session method.** Rejected. List, resume, and close complete the durable automation lifecycle. Load/replay, delete, and fork introduce separate transcript, destructive-storage, and lineage semantics that this use case does not require. + +**Use unstable provider methods for model discovery.** Rejected because standard session configuration options express the choice and remain scoped to the session. + +**Copy every DSH runtime field to ACP metadata.** Rejected because exact token breakdowns, private result statuses, programmatic display names, and per-MCP tunables have no stable ACP v1 equivalent. + +## Verification + +Focused tests cover exact capability advertisement without private metadata; model/reasoning choices, invalid and concurrent mutation, topology updates, and image-route pinning; stdio/HTTP MCP setup, declaration rollback, scope isolation, resume, and disposal; list pagination, canonical workspace checks, active conflicts, close/resume, and restart recovery; message/thought/tool/usage order and ids; tool-before-permission order; standard stop reasons; request and session cancellation; and connection-loss teardown. + +A generic keyless conformance test boots the real ACP demo twice and uses only the public ACP SDK to select a model and reasoning effort, attach an MCP server, execute a tool turn, observe standard updates, close, restart, list, resume, and cancel. It contains no integration-specific names, dependencies, metadata, or environment behavior. + +## Consequences + +External automation projects can use DSH through stable ACP v1 instead of maintaining a DSH-specific runtime protocol. The bridge is a larger control surface but remains smaller than a UI: it owns lifecycle and semantic interoperability, while human presentation and interaction stay in product clients. + +Persistent lifecycle and request MCP mounting make session creation stricter. Misconfiguration and initial MCP failure reject before publication, and close waits for real quiescence and persistence. This cost is the ownership proof required to avoid partial Agents, leaked tools, or orphaned processes. diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md new file mode 100644 index 0000000000..2e1348a4f2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md @@ -0,0 +1,79 @@ +# Agent Note:标准 ACP v1 自动化控制 + +状态:已实现 + +[English](2026-08-22-standard-acp-automation-controls.md) | 中文 + +> 本说明仅取代 [ACP 作为纯自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md) 中仅支持提示词的协议清单。该决策关于禁止 ACP 成为第二套产品 UI 的规定仍具权威性。 + +## 问题 + +纯自动化 ACP 桥接层可以创建新会话、一次提交一个提示词、取消提示词、接收已提交 assistant 消息,并回答一次性权限请求。通用外部自动化控制器仍需依赖私有进程知识,才能发现模型、挂载 MCP 服务器、在重启后找到持久会话、恢复会话、独立关闭一个会话,以及观察 reasoning、工具或上下文压力进度。如果在集成专用 runtime 中复制这些控制,ACP 只会名义上可互操作,而 DSH 自动化仍依赖私有旁路协议。 + +稳定 ACP v1 协议已经定义所需的控制词汇。增加私有 `_meta`、自定义方法、用例专用环境处理或展示投影会割裂该词汇,并重新引入纯自动化决策已经移除的 UI 耦合。 + +## 决策 + +`@deepseek-ai/dsh-acp` 实现通用控制器需要的完整标准 ACP v1 自动化子集:`session/new`、`session/list`、`session/resume`、`session/close`、`session/prompt`、`session/cancel`、`session/set_config_option`、JSON-RPC `$/cancel_request`、`session/update` 和 `session/request_permission`。仓库内每条连接的两端都使用 `@agentclientprotocol/sdk` 1.4 的 app/context 接口。 + +能力会省略未支持的方法和功能。DSH 不增加自定义方法、能力标记或 `_meta`,也不为客户端元数据赋予私有含义。`session/load`、`session/delete`、`session/fork`、附加目录、SSE 和 ACP 传输 MCP、模式、命令、计划、终端、客户端文件系统操作和 elicitation 仍不受支持。会话控制和语义更新是自动化协议数据;它们不会使 ACP 成为人工 UI。 + +## Per-session 所有权 + +每个已公布 Agent 由一个 `AcpSession` 模块拥有,该模块同时拥有所选模型状态、请求 MCP 挂载、单提示词槽位、有序更新链和记忆化关闭操作。全局事件监听器只识别确切 Agent 或 Session,再委托给该模块。模块会在内存中把准入快照与已识别消息关联到 inbox claim 时刻,再将其固定到已准入轮次。因此,图片能力检查、提示词变量、请求 header 和每个模型步骤都使用同一个提供方/模型/reasoning tuple,而普通持久用户 source 保持不变。并发配置变更从下一个 ACP 轮次开始生效。 + +显式 `session/close`、连接丢失和插件释放调用同一个关闭操作。它会先取消准入和 Agent 工作,再等待;随后 drain 已提交更新和可继续后代、flush 持久化,并释放 Agent scope 及其 MCP 客户端。关闭流程会保留事件路由直到 drain 完成。只有所有自有会话 teardown 都完成后才报告失败,其他前端的 Agent 和后代不受影响。 + +## 持久会话控制 + +完整 ACP 生命周期支持要求挂载会话持久化。`session/list` 读取已实体化的顶层 header,排除活动会话和后代会话,按规范物理 `cwd` 过滤,按创建时间和 id 排序,并通过不透明 keyset cursor 返回有界页面。摘要有意省略标题和展示元数据。 + +`session/new` 会显式要求持久化在不虚构会话事件的情况下实体化 live session header,因此即使空会话也可以关闭、列出和恢复。其他前端仍保留持久化 seam 的惰性默认行为,不会实体化被放弃的空会话。`session/resume` 拒绝活动 id,以及非顶层或未知的持久 id;在组合 Agent 前校验请求的规范 `cwd`;恢复持久日志但不向客户端重放;挂载该请求提供的 MCP 声明。`session/close` 让持久日志可供后续进程使用。 + +持久化有意把 `create(meta)` 视为 live registration:JSONL 在首次追加事件前不创建 artifact,SQLite 在此之前不创建 row。该默认行为会移除被放弃的空会话,但 ACP 不能继承它,因为 `session/new` 会在任何提示词出现前公布会话身份,而进程可能在返回成功响应后、收到 `session/close` 前停止。桥接层只在 Agent 和 MCP 组合成功后、返回 `session/new` 前执行实体化;组合失败仍不留下残留物,每个已返回 id 则都能在重启后继续存在。 + +`ensureMaterialized(session)` 接收确切 live Session,使 coordinator 先 flush 该会话,再通过现有 per-session 写入链,使用已注册的不可变 header 串行执行仅 header 实体化。JSONL 写入一个 header frame,SQLite 写入一条 metadata row;重复调用幂等,不支持该能力的 backend 会让会话创建失败,而不会承诺无法提供的可恢复性。让 `create` 全面 eager 会改变所有前端放弃会话的行为;追加 synthetic event 会仅为触发存储而虚构 sequence 与 replay 事实;等到关闭时再写入则会让持久性与进程丢失竞争。 + +## 标准配置选项 + +建议性 LLM catalog 现在服务于另一个自动化 consumer,但不会成为请求校验。ACP 公开按提供方分组的 `model` select,其不透明值保留提供方/模型对;还会公开来自已解析确切模型的依赖 `reasoning_effort` select。具有 efforts 但没有 adapter 配置默认值的模型会包含 `Provider default`,以保留省略状态并让提供方自行选择。新建、恢复和设置响应都返回完整状态。Adapter 拓扑事件发出 `config_option_update`;每个会话按接收顺序串行处理变更。配置的 ACP 提供方/模型仍是初始选择;未列出的配置路由会合成到返回选项中,而不会被拒绝。 + +## 标准 MCP 映射 + +`session/new` 和 `session/resume` 接受标准 stdio 和 Streamable HTTP MCP 声明。Stdio 使用会话 `cwd`;HTTP 使用已声明 URL 和 header;两者都保留 `dsh-mcp-client` 的超时和重连默认值。名称、命令、URL、环境项、header 和重复的规范化 namespace 都会在 Agent 公布前校验。初始连接或发现失败会回滚尚未公布的 Agent。 + +MCP namespace reservation 跟随最近的 DSH registration scope,而不是进程 root。独立 Agent scope 可以使用同一服务器名,同一 Agent 内的重复名称仍会失败。Scoped disposal 会释放工具、传输和 reservation。 + +ACP 客户端是受信任的控制器:stdio 声明授权执行进程,HTTP 声明授权携带其 header 发起请求。DSH 不增加每服务器私有 cwd 或超时字段。工具挂载后,普通 DSH 工具策略仍然约束调用。 + +## 语义更新投影 + +只有已提交的持久事实会进入 `session/update`。Assistant 文本/图片变成 `agent_message_chunk`;reasoning 变成 `agent_thought_chunk`;工具调用/结果变成通用 `tool_call` 和 `tool_call_update`;已知的测量上下文压力与容量变成 `usage_update`;adapter 拓扑变化变成 `config_option_update`。持久消息 id 和工具调用 id 保留关联。规范 DSH 工具名作为标准工具调用 title。 + +Per-session 链会串行处理所有更新,并在提示词完成前 drain。引用工具调用的权限请求只会在该工具调用通知 drain 后发送。原始模型 delta、重试尝试、卡片、终端状态、diff、位置、计划、标题、todo 和不受支持内容不会进入 wire。 + +`session/cancel` 和 `$/cancel_request` 进入同一个提示词自有取消路径。关联结尾只映射到标准 stop reason 和 JSON-RPC error;模型输出达到上限时报告 `max_tokens`。ACP 不返回额外 DSH 结果结构。 + +## 考虑过的替代方案 + +**增加私有控制器扩展。** 已拒绝,因为标准 ACP v1 已经承载所需生命周期、配置、MCP、取消、权限和语义更新概念。私有扩展会使通用 SDK 客户端不完整。 + +**恢复之前的编辑器投影。** 已拒绝,因为计划、终端、diff、卡片、导航和人工 elicitation 属于展示职责。语义工具和 reasoning 事实可以作为有用的自动化遥测,而无需导入展示模块。 + +**实现所有 ACP 会话方法。** 已拒绝。列出、恢复和关闭已经完成持久自动化生命周期。加载/重放、删除和 fork 会引入本用例不需要的独立 transcript、破坏性存储和 lineage 语义。 + +**使用不稳定 provider 方法发现模型。** 已拒绝,因为标准会话配置选项可以表达该选择,并保持会话 scope。 + +**把每个 DSH runtime 字段复制到 ACP 元数据。** 已拒绝,因为精确 token 明细、私有结果状态、程序化展示名称和每 MCP tunable 没有稳定 ACP v1 对应项。 + +## 验证 + +聚焦测试覆盖:无私有元数据的确切能力公布;模型/reasoning 选择、无效和并发变更、拓扑更新以及图片路由固定;stdio/HTTP MCP 设置、声明回滚、scope 隔离、恢复和释放;列表分页、规范 workspace 校验、活动冲突、关闭/恢复和重启恢复;消息/思考/工具/用量顺序与 id;工具先于权限;标准 stop reason;请求和会话取消;连接丢失 teardown。 + +通用 keyless conformance 测试会启动真实 ACP demo 两次,并且只使用公开 ACP SDK:选择模型和 reasoning effort、挂载 MCP 服务器、执行工具轮次、观察标准更新、关闭、重启、列出、恢复和取消。它不包含集成专用名称、依赖、元数据或环境行为。 + +## 后果 + +外部自动化项目可以通过稳定 ACP v1 使用 DSH,而无需维护 DSH 专用 runtime 协议。桥接层的控制接口变大,但仍小于 UI:它拥有生命周期和语义互操作,而人工展示和交互仍属于产品客户端。 + +持久生命周期和请求 MCP 挂载让会话创建更严格。配置错误和初始 MCP 失败会在公布前拒绝,关闭会等待真实完全停稳和持久化。这是避免部分 Agent、泄漏工具或孤儿进程所需的所有权证明。 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index 6122ba2771..a215353107 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.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/process/2026-06-16-pnpm-over-yarn.md -2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1 -2026-06-16-pnpm-over-yarn.zh.md: c2c289ad2e43b6bdcb0997e9a4e18421d6705c6a +2026-06-16-pnpm-over-yarn.md: c42616f8d1dcacd925719bc8662683f04022573b +2026-06-16-pnpm-over-yarn.zh.md: df2adbb3873415e2c732f4eb5f876f17e3c86003 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index 30b34c65fd..c42616f8d1 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -12,11 +12,12 @@ The switching cost is at its lowest right now. Nothing publishes from this repo ## Decision -Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed through Corepack (same mechanism Yarn used): +Adopt **pnpm 11.7.0**, pinned via the `packageManager` field. Contributor setup uses Corepack, while CI installs that pin through `pnpm/action-setup`: - **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs). - **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist. - **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript. +- **Shell-free package-manager re-entry.** Repository scripts that start another pnpm command resolve `npm_execpath` by file form: `.js`, `.cjs`, and `.mjs` entries run under the current Node executable, while native and shebang executables run directly. Neither path uses a shell, so command paths and arguments retain their literal contents across platforms. The [native Windows pull-request job](2026-08-08-native-windows-pull-request-ci.md) provisions `@pnpm/exe`, so its complete inventory supplies a real PE-entry integration signal. - **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. - All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy. @@ -25,6 +26,8 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu - **Keep Yarn 4** — zero churn, but bets on the less-traveled linker mode and a constraints engine tied to one package manager. - **npm workspaces** — ubiquitous, but no constraints story and weaker monorepo ergonomics. - **pnpm with the hoisted linker** — smoother migration, but throws away the phantom-dependency safety that is the main correctness reason to move. +- **Always run `npm_execpath` through Node** — works for pnpm's JavaScript distribution but asks Node to parse the ELF, Mach-O, or PE executable supplied by `@pnpm/exe`. +- **Run re-entry commands through a shell** — accepts more launcher forms but changes quoting, metacharacter expansion, executable resolution, and signal behavior for every child command. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index c2c289ad2e..df2adbb387 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -12,11 +12,12 @@ Status: implemented ## 决策 -采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同): +采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本。贡献者环境使用 Corepack,CI 则通过 `pnpm/action-setup` 安装该固定版本: - **Workspaces** 从 `package.json` 的 `workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——同样的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 - **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.zh.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 - **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 +- **无 shell 的包管理器再进入。** 需要启动另一条 pnpm 命令的仓库脚本按文件形式解析 `npm_execpath`:`.js`、`.cjs` 和 `.mjs` 入口由当前 Node 可执行文件运行,原生及带 shebang 的可执行入口则直接运行。两条路径都不使用 shell,因此命令路径和参数在各平台上均保留字面内容。[原生 Windows 拉取请求作业](2026-08-08-native-windows-pull-request-ci.zh.md)会提供 `@pnpm/exe`,因此其完整清单会产生真实的 PE 入口集成信号。 - **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查是否为私有。 - 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。 @@ -25,6 +26,8 @@ Status: implemented - **保留 Yarn 4**——零变动,但押注于使用率较低的链接器模式和一个绑定单一包管理器的约束引擎。 - **npm workspaces**——无处不在,但没有约束方案,monorepo 开发体验也较差。 - **pnpm 搭配提升式链接器**——迁移更平滑,但放弃了幻影依赖安全性,而这正是迁移的核心正确性理由。 +- **始终通过 Node 运行 `npm_execpath`**——适用于 pnpm 的 JavaScript 分发,但会让 Node 尝试解析 `@pnpm/exe` 提供的 ELF、Mach-O 或 PE 可执行文件。 +- **通过 shell 运行再进入命令**——可接受更多启动器形式,但会改变每条子命令的引号、元字符展开、可执行文件解析和信号行为。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index afd9d3c5e7..c5839b97b2 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.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/process/2026-07-02-bilingual-docs-and-pairing-gate.md -2026-07-02-bilingual-docs-and-pairing-gate.md: 3a93b4aa68e6f6092abf42a40ec148a205a78691 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: e145afe7cff8d0d0632541110d1c0f2bf70d11f2 +2026-07-02-bilingual-docs-and-pairing-gate.md: 8fbda5e8987d7c575e3ef96f1724b04dd7070621 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: b022abf61be733368263ff3de4176e43832b09a2 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 3a93b4aa68..8fbda5e898 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,13 +13,13 @@ This repo's documentation corpus is read by people and agents inside and outside - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write `, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, the Chinese side and every authored English source carry their switchers while listed generated English sources are exempt, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. Relative document links whose targets belong to that active corpus use the target sibling matching the source locale, while the structure signature normalizes `.md` and `.zh.md` siblings to one semantic target and retains the exact query/fragment suffix; the [localized bilingual links decision](2026-08-18-localized-bilingual-links.md) owns that refinement. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch. -- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. +- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. Repository-root policy documents are named explicitly: `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` participate in the same discovery and pairing rules even though they are outside documentation directories. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it. - **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration. - **Translation is agent work with human review.** Routine changes use the direct one-pass path owned by the [lightweight-translation decision](2026-08-08-lightweight-routine-documentation-translation.md). The [extended translation skill](../../../skills/dsh-translate-docs/SKILL.md) retains delegated translation and the other heavier mechanisms for explicit user invocation; both paths defer to the documentation contracts as their sources of truth. ## Verification -The verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible. +The verification contract covers each boundary independently. `verify-translation-pairing` pins pair completeness, hashes, switchers, and structure, while its discovery tests pin the named root policy documents and automatic README coverage; [`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) pins locale-specific source selection for published pairs; [`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) pins discovery of Loader YAML and exclusion of translation records; and the [translation-prompt runnable snapshot](../../../../scripts/translation-prompt.snapshot.ts) pins the rendered system message, five reviewed example pairs, source request, and consumed response. Together these checks make pair drift, publication drift, configuration misclassification, and model-visible prompt drift review-visible. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index e145afe7cf..b022abf61b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,13 +13,13 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.zh.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.zh.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 Git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write `,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 YAML diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两侧的 hash 均与记录匹配、中文侧和所有人工撰写的英文源都带语言切换行而清单内的生成英文源除外、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。目标属于该活跃语料的相对文档链接使用与源文件 locale 相同的目标兄弟文件;结构签名则把 `.md` 与 `.zh.md` 兄弟文件规范化为同一个语义目标,并保留完全相同的 query/fragment 后缀;该细化规则由[双语文档链接本地化决策](2026-08-18-localized-bilingual-links.zh.md)负责。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。 -- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 +- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。仓库根目录的政策文档会被显式点名:`CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 虽然不在文档目录中,仍遵循同一套发现与配对规则。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。 - **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。 - **翻译是 agent 的工作,由人评审。** 常规改动采用由[轻量翻译决策](2026-08-08-lightweight-routine-documentation-translation.zh.md)确立的直接单遍路径。[扩展翻译 skill(技能)](../../../skills/dsh-translate-docs/SKILL.md)保留委派翻译和其他较重机制,供用户显式调用;两条路径均以文档契约为真源。 ## 验证 -验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、语言切换行和结构;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和所消费的响应。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。 +验证约定分别覆盖每个边界。`verify-translation-pairing` 固定配对完整性、hash、语言切换行和结构,其发现测试则固定具名的根目录政策文档与自动 README 覆盖;[`project-doc-site.spec.ts`](../../../../scripts/project-doc-site.spec.ts) 固定已发布配对按 locale 选择对应源文件;[`cordis-config-files.spec.ts`](../../../../scripts/cordis-config-files.spec.ts) 固定 Loader YAML 的发现以及翻译记录的排除;[翻译提示词可运行快照](../../../../scripts/translation-prompt.snapshot.ts)则固定渲染后的系统消息、五对经评审的示例、源请求和所消费的响应。这些检查共同使配对漂移、发布漂移、配置误分类和模型可见提示词漂移都可在评审中看见。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml index 6c1c7fa37b..81d27de0ca 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.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/process/2026-07-06-node-engine-floor.md -2026-07-06-node-engine-floor.md: 4d24a861bfa1a27b5aa4e07da1ed21452c7be566 -2026-07-06-node-engine-floor.zh.md: 180bfafb913f6af4d795a9b879004ceb4e7d12a3 +2026-07-06-node-engine-floor.md: 44dc09f32b855f11a1cfd7851a96427b9f471848 +2026-07-06-node-engine-floor.zh.md: ebd7d20fe0608a7895cab87ba3c1ea405996a062 diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md index 4d24a861bf..44dc09f32b 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md @@ -19,7 +19,7 @@ Two Node features gate the source runtime: Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use. -`@types/node` remains pinned to the 22.x line (`^22.20.0`) to match the LTS support line: reaching for a Node 23+/24+/25+ API fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only a floor matrix leg could catch. The whole tree typechecks clean against the Node 22 type API today, so the pin costs nothing. +`@types/node` remains pinned to the 22.x line (`^22.20.0`) to match the LTS support line: reaching for a Node 23+/24+/25+ API fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only a floor matrix leg could catch. The whole tree typechecks clean against the Node 22 type API, so the pin costs nothing. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md index 180bfafb91..ebd7d20fe0 100644 --- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md @@ -19,7 +19,7 @@ Status: implemented 这些源码特性在 22.x 线上于 **22.18** 全部就绪,但已安装的 Pi 适配器依赖将宣传的 LTS 下限进一步提高。`@deepseek-ai/dsh-llm-pi-ai` 依赖 `@earendil-works/pi-ai@0.79.3`,后者的包声明 `engines.node >=22.19.0`,因此 LTS 下限为 **22.19**。24.x 分支保持 `>=24.0.0`。该不相交范围完全排除了 Node 23:Node 23.0–23.5 至少还有一个源码特性需要标志,而 23 线是非 LTS/已 EOL 的,宣传 `>=23.6` 会增加一条已终止的发布线和一条 CI 分支,而没有任何部署应当使用它。 -`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是先编译通过,直到下限矩阵分支运行时才暴露错误。目前整个代码树针对 Node 22 类型 API 的类型检查全部通过,因此固定该版本不产生任何代价。 +`@types/node` 继续固定在 22.x 线(`^22.20.0`),以匹配 LTS 支持线:使用 Node 23+/24+/25+ 的 API 会在所有机器和类型检查门禁中导致 `tsc` 失败,而不是先编译通过,直到下限矩阵分支运行时才暴露错误。整个代码树针对 Node 22 类型 API 的类型检查全部通过,因此固定该版本不产生任何代价。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index ddb7f26b1e..5b8638a3c5 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.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/process/2026-07-06-parallel-pre-push-gates.md -2026-07-06-parallel-pre-push-gates.md: 2ae08b8c87939085a0f8c7e0cb3ac69fb3ab8e91 -2026-07-06-parallel-pre-push-gates.zh.md: 0abe9af4113b5be36bebc1bc2e421be6c805f851 +2026-07-06-parallel-pre-push-gates.md: 22d69478f0fe664b91c4ada2c5c97e7c61ee7deb +2026-07-06-parallel-pre-push-gates.zh.md: 98de527688399b8f6c09e91f55916361bf79d12d diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 2ae08b8c87..22d69478f0 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -20,11 +20,11 @@ The Node 24 consumer job is one ten-gate mode rather than a shell-owned process [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. -The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md)). +The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` invokes a scheduler mode containing the same thirteen checks with the local four-worker cap, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md)). ## Verification -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins pass-required and settle-only ordering, pins the consumer and native Windows inventories and their failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins pass-required and settle-only ordering, pins the hygiene, consumer, and native Windows inventories and their failure semantics, exercises signal termination through a real child process, and proves that streamed output is immediate and unbuffered. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 0abe9af411..98de527688 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -20,11 +20,11 @@ Node 24 消费方任务采用单个包含 10 道门禁的模式,而非由 shel [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 -各门禁的包脚本仍是临时本地运行所用的命令入口。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 的成员列表由调度器管理([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 +各门禁的包脚本仍是临时本地运行所用的命令入口。`hygiene` 调用包含相同十三道检查且限制为本地四个 worker 的调度器模式,而 `doc-sync` 的成员列表由调度器管理([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 ## 验证 -[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定必须通过与只等结算两种顺序,锁定消费方与原生 Windows 清单及其失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定必须通过与只等结算两种顺序,锁定 hygiene、消费方与原生 Windows 清单及其失败语义,通过真实子进程验证信号终止,并证明流式输出会立即显示且不被缓冲。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 27520449a0..83d446e0f6 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: 10b8ac8761ef6cc522e752780324c11dfda23622 -2026-07-13-documentation-site-projection.zh.md: 386ce50ae5e73133d5a4464997fc7d63e654fd70 +2026-07-13-documentation-site-projection.md: eaaf7cfc50b5aba348533ea4f5a4a1f50641f30f +2026-07-13-documentation-site-projection.zh.md: c954fcc41c874007330a66b93cea50fef01f8e3e diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 10b8ac8761..eaaf7cfc50 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -16,7 +16,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g `scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. -Locale home projections retain only the canonical YAML frontmatter. The repository-facing body keeps its H1 and bilingual source links, while the frontmatter implements the [locale-preserving quick-start redirect](../simplification/2026-08-11-quickstart-documentation-home.md) and the site navigation owns locale switching. +Locale home projections retain only the canonical YAML frontmatter. The repository-facing body keeps its H1 and bilingual source links, while the frontmatter implements the [locale-preserving quick-start redirect](../../../../docs/user/index.md) and the site navigation owns locale switching. The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the `deepseek-ai/deepseek-harness` repository home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. @@ -26,6 +26,8 @@ The projector parses Markdown links without reserializing the document. A link t Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. +The configured Markdown renderer keeps a build-local cache for non-Mermaid, non-snippet code fences, keyed by the exact content, info string, delimiter, and token attributes. About half of the bilingual projection's code fences repeat, so Shiki renders each distinct representation once. Mermaid fences bypass the cache because the plugin output includes a token-position id, and VitePress snippets resolve their source files during rendering. The cache is enabled only for production builds, is discarded with the renderer after each build, and does not change the emitted fence HTML. + Site publication remains separate from site construction. A dedicated GitHub Actions workflow runs the existing documentation gates, uploads `website/.dist` as a Pages artifact, and deploys only after the build succeeds. `actions/configure-pages` supplies the destination's base path to VitePress at build time, so the private Pages origin, a later public project path, and a custom domain do not require distinct checked-in configurations. Pages visibility remains a repository hosting setting rather than a workflow permission. ## Alternatives considered @@ -44,6 +46,6 @@ Site publication remains separate from site construction. A dedicated GitHub Act ## Consequences -Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. The layout gate makes an obsolete site-specific Markdown tree a merge failure instead of ignored build input. Merges that affect the documentation site deploy the checked result to Pages, while manual dispatch provides a recovery and validation entry point. +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. The layout gate makes an obsolete site-specific Markdown tree a merge failure instead of ignored build input. Publication to Pages runs as a manual dispatch from a release tag, so the site presents a released snapshot rather than the current master ([why](2026-08-21-documentation-site-tag-release.md)). The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 386ce50ae5..c954fcc41c 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -16,7 +16,7 @@ Status: implemented 在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由命名。每个页面都会获得一个指向其权威仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 -各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文保留其 H1 和双语源文件链接;frontmatter 实现[保持 locale 不变的快速开始重定向](../simplification/2026-08-11-quickstart-documentation-home.zh.md),网站导航负责切换 locale。 +各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文保留其 H1 和双语源文件链接;frontmatter 实现[保持 locale 不变的快速开始重定向](../../../../docs/user/index.zh.md),网站导航负责切换 locale。 投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 `deepseek-ai/deepseek-harness` 仓库主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.zh.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 @@ -26,6 +26,8 @@ Status: implemented Mermaid 渲染权威图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 +配置后的 Markdown 渲染器会在单次构建期间缓存非 Mermaid、非 snippet 代码围栏,缓存键包含确切正文、info string、分隔符和 token 属性。双语投影中约半数代码围栏是重复内容,因此 Shiki 只渲染一次每种不同表示。Mermaid 围栏不会进入缓存,因为插件输出包含 token 位置 id;VitePress snippet 则在渲染期间解析源文件。缓存仅在生产构建中启用,每次构建后会随渲染器一同丢弃,并且不会改变生成的围栏 HTML。 + 网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。 ## 考虑过的替代方案 @@ -44,6 +46,6 @@ Mermaid 渲染权威图表。网站工作区显式声明 `vitepress-plugin-merma ## 后果 -文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视权威输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。影响文档网站的合并会把检查过的结果部署到 Pages,手动触发则提供恢复和验证的入口。 +文档事实只有一个可编辑归属,公开路由在源文件移动后仍保持稳定,网站也能纳入生成的参考资料而无需提交另一份生成副本。本地开发会监视权威输入并重新生成一次性投影。布局门禁会把陈旧的网站专用 Markdown 目录树变成合并失败,而不是被忽略的构建输入。向 Pages 的发布以从发布 tag 手动 dispatch 的方式运行,因此站点呈现的是已发布快照而非当前的 master([原因](2026-08-21-documentation-site-tag-release.zh.md))。 发布 manifest 是一份需要维护的 allowlist,链接投影也引入了一层仓库专用的构建适配器。新增一种 Markdown 链接行为时,需要增加投影器测试。Mermaid 支持也会增大客户端 bundle,但能保留权威文档中已经使用的图表。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 6b81570528..df45c55f57 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: 20e2709d070439f33664d07c6424c864cd84e850 -2026-07-20-gui-testing-system.zh.md: 88490dc90b34488391a80c155f423336d64adc3d +2026-07-20-gui-testing-system.md: 8656507ba85b187fea333b100762d4250256845f +2026-07-20-gui-testing-system.zh.md: d9dee0d531bfebafca02bbe765a271ee39e6e6a0 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 20e2709d07..8656507ba8 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -2,7 +2,7 @@ Status: implemented -> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership/registry semantics are tier-2 territory (`runtime` + `ui-slots` suites), not component specs. +> Path update (2026-08-23, Controller split): the three-tier philosophy and golden-path method here remain current; object-layer specs now live across `packages/api/session-controller/tests/` and `packages/test-support/client-runtime/tests/`, while wire specs remain in `packages/client/connection/tests/`. Component specs are per-plugin jsdom suites under each `packages/client/*/tests/`. Component-spec shape follows the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md): feed props directly — the store share comes from `createXXXStore().create()` (the real engine, the sanctioned zero-machinery path), framework hooks are plain stubs; no render machinery, no provider mounting. Slot ownership and registry semantics are tier-2 territory (`ui-renderer` + `ui-slots` suites), not component specs. English | [中文](2026-07-20-gui-testing-system.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 88490dc90b..d9dee0d531 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -2,7 +2,7 @@ Status: implemented -> 路径更新(2026-07-22,插件体系重构):本文三层理念与黄金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.zh.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的无额外机制路径),框架钩子用普通桩;无渲染机制、不挂载提供方。slot 归属/注册表语义归 2 层地界(`runtime` + `ui-slots` 套件),不归组件 spec。 +> 路径更新(2026-08-23,Controller 拆分):本文三层理念与黄金路径方法仍为现行;对象层 spec 现分布于 `packages/api/session-controller/tests/` 和 `packages/test-support/client-runtime/tests/`,wire spec 仍位于 `packages/client/connection/tests/`。组件 spec 是各 `packages/client/*/tests/` 下的插件级 jsdom 套件。组件 spec 形态遵循 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.zh.md):props 直喂——store 份额来自 `createXXXStore().create()`(真引擎,获认可的无额外机制路径),框架钩子用普通桩;无渲染机制、不挂载提供方。slot 归属和注册表语义归 2 层地界(`ui-renderer` + `ui-slots` 套件),不归组件 spec。 [English](2026-07-20-gui-testing-system.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index ab409d231d..a76dd67754 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 65d6166773b09446f0d00e749678accede58e5eb -2026-07-22-product-first-root-readme.zh.md: 4f289a96ffdf9538627988feba8fc3a77cd7d719 +2026-07-22-product-first-root-readme.md: 24c6dc04f24bd758d8955824a17b1d99801ecd16 +2026-07-22-product-first-root-readme.zh.md: f5c0d821e916423c92258649c22ceed222c06439 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 65d6166773..24c6dc04f2 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -16,7 +16,7 @@ A note before installation thanks internal testers, states that features and exp The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. -Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps a separate [quick-start entry route](../simplification/2026-08-11-quickstart-documentation-home.md) instead of presenting another product landing page. +Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps a separate [quick-start entry route](../../../../docs/user/index.md) instead of presenting another product landing page. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index 4f289a96ff..f5c0d821e9 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -16,7 +16,7 @@ Status: implemented 用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 -包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站保留独立的[快速开始入口路由](../simplification/2026-08-11-quickstart-documentation-home.zh.md),不另行呈现产品首页。 +包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站保留独立的[快速开始入口路由](../../../../docs/user/index.zh.md),不另行呈现产品首页。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 0bfe6c03b5..3f61c0bb86 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 6520a16fb4aa5f03e364a17392c87fe0df459ea1 -2026-07-23-portable-required-pull-request-ci.zh.md: cf57ae0d409fca750b5e33e533b8650c747f1abf +2026-07-23-portable-required-pull-request-ci.md: 00a58136b8e6d5a2f282bede9876d2f96e6ddf52 +2026-07-23-portable-required-pull-request-ci.zh.md: e367408850efe97457f4921150d39a1cfe34aed3 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 6520a16fb4..00a58136b8 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) (pull-request-only) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, the Python SDK unit suite, and the [release-shaped Linux x64 Python runtime validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), while the serial references (in `ci-master.yml`) remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) (pull-request-only) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard-hosted jobs retain Node 22.19, Node 26, the Python SDK unit suite, and [installed-wheel Python runtime validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on every published native target, while the serial references (in `ci-master.yml`) remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index cf57ae0d40..e367408850 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml)(仅 pull request)在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26、Python SDK 单元测试套件与[发布形态的 Linux x64 Python 运行时验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md),串行参考流程(在 `ci-master.yml` 中)仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml)(仅 pull request)在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md))。标准托管 job 保留 Node 22.19、Node 26、Python SDK 单元测试套件,并在每个已发布原生目标上运行[安装后 wheel Python 运行时验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md);串行参考流程(在 `ci-master.yml` 中)仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index d0c312c97c..4fe05d0ede 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: 1c032fa07167ec2407d0f46707f942ba0c830494 -2026-07-26-briefed-minimal-translation-updates.zh.md: 9cb0d965f37442c9e6d3587d23aa340368fc9fa0 +2026-07-26-briefed-minimal-translation-updates.md: 049e0a2771c65972bf42bae62cf1a18a11841b83 +2026-07-26-briefed-minimal-translation-updates.zh.md: 1574617bd10e020853ea11c345d5caebfc98cae4 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index 1c032fa071..049e0a2771 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -34,7 +34,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp - **Whole-document re-translation as the update path** (what a naive pipeline does) — rejected on benchmark evidence: preservation collapse, terminology drift, highest cost. The contract's minimal-update rule survives with data behind it. - **Batching several pairs per subagent** — rejected: no measured saving (briefings already deduplicate the fixed content), and one stalled or confused pair holds the others hostage. - **Per-paragraph translation-memory records in the sidecar** (segment hashes instead of whole-file hashes) — rejected: paragraph boundaries may legitimately differ across the pair, either side can be authored first, and the records would bloat and conflict in merges. Span mapping computed on demand from the existing whole-file hashes recovers the same alignment when it is trustworthy and says so when it is not. -- **An update mode in the automated prompt pipeline (prompt-v5)** — deferred, not designed here: nothing drives [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts) today, and the agent path was the live cost center. The pipeline keeps its whole-document v4 contract until it has a consumer. +- **An update mode in the automated prompt pipeline (prompt-v5)** — deferred, not designed here: no shipped consumer drives [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts), and the agent path was the live cost center. The pipeline keeps its whole-document v4 contract until it has a consumer. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index 9cb0d965f3..1574617bd1 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -34,7 +34,7 @@ Status: implemented - **把整篇重译作为更新路径**(朴素流水线的做法):依据基准测试证据否决,理由是保留度崩塌、术语漂移、成本最高。约定的最小更新规则得以延续,且从此有数据支撑。 - **每个 subagent 批量处理多对文档**:否决。没有实测出节省(简报本身已对固定内容做了去重),而且一对文档停滞或陷入混乱会把其余配对一并拖住。 - **在伴随记录中保存逐段的翻译记忆条目**(用分段 hash 取代整文件 hash):否决。配对两侧的段落边界可以合理地不同,任一侧都可能先撰写,这类条目还会不断膨胀并在合并时产生冲突。基于现有整文件 hash 按需计算的区间映射,在对齐可信时能恢复同样的对齐关系,不可信时会明确说明。 -- **给自动提示词流水线加一个更新模式(prompt-v5)**:推迟,本文不做设计。今天没有任何调用方在驱动 [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts),实际的成本中心是 agent 路径。流水线在拥有消费方之前,维持其整篇文档的 v4 约定。 +- **给自动提示词流水线加一个更新模式(prompt-v5)**:推迟,本文不做设计。没有已交付消费方驱动 [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts),实际的成本中心是 agent 路径。流水线在拥有消费方之前,维持其整篇文档的 v4 约定。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 9aa6dfc8d2..aec2498598 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: c998986501eacfca88c8f7125f7ff4bc7f9a7101 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 6e1b61e64995d0fb68c7509a0cd374b2a98d16ae +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: d485098f7ee04596e77322089fa0f6f45020024a +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 47bd525377d6c358891b238373410049987f5ee1 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index c998986501..d485098f7e 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -23,7 +23,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i - **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. - **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. - **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents. -- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. +- **A composite action wrapping action-setup plus setup-node.** Rejected: the remaining per-job variation (Node-version matrices, per-platform conditional caching, and the restore-only pairing) is deliberate policy, not boilerplate. A wrapper would grow matching inputs or flatten a real asymmetry, while the two-line pair is already near the floor. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 6e1b61e649..47bd525377 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -23,7 +23,7 @@ Status: implemented - **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一端——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 - **只转换带缓存的工作流,留下其余出现 `corepack enable` 的位置。** 否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 - **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。 -- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意采用的策略而非样板——包装层要么不得不增加与这些差异一一对应的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 +- **用一个组合 action 包装 action-setup 加 setup-node。**不予采纳:剩余的按作业差异(Node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意采用的策略而非样板。包装层要么需要增加与这些差异一一对应的输入,要么会抹平真实的不对称,而两行组合已经接近下限。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml index 78ff70c1c1..dc243ba95c 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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/process/2026-07-31-coverage-exempt-heavy-suites.md -2026-07-31-coverage-exempt-heavy-suites.md: 1f468a69321b451593a9279cfebc1b457fb08a47 -2026-07-31-coverage-exempt-heavy-suites.zh.md: 7e519f44c8321b6b99c04c6af56c4cfa5b641663 +2026-07-31-coverage-exempt-heavy-suites.md: 35642c41c0140b5a39be7da4668b33b70f858a85 +2026-07-31-coverage-exempt-heavy-suites.zh.md: cefade080581e4c20a71269bef638e12559153ae diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md index 1f468a6932..35642c41c0 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -10,6 +10,8 @@ The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handfu The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information. +The Web Worker transform corpus exposed the same waste on native Windows: `transform-corpus.spec.ts` spent 279 seconds inside one 442-second coverage partition while the other seven partitions settled in 110–161 seconds. Its real checker runs package source only in a spawned Node process, outside the parent Vitest worker's v8 coverage session, so the slow partition produced no threshold data from that work. + ## Decision The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax: @@ -17,10 +19,12 @@ The `ci-coverage` aggregate splits into two parallel gates; every test still run - **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. - **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. -Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. +Linux coverage CI and native Windows CI use [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) inside the instrumented gate. Its merged report carries the same threshold proof; the exempt gate and its membership rules remain unchanged. Linux overlaps four partition children, two exempt workers, and up to eight corpus children, so this combined fan-out is the first check if that lane regresses. Native Windows runs the exempt gate after the instrumented merge, while the lightweight observational inventory overlaps the exempt work, so the full-corpus child does not compete with sixteen coverage processes. The Oxlint contract suite atomically publishes scanner-valid temporary package probes and hides its script-only probes from glob discovery. `scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. +`transform-corpus.spec.ts` discovers the complete built-bundle set once, assigns every path to exactly one of up to eight non-empty Node-loader children, and asserts the shard union before launch. `client-runtime` follows `acp-snapshot` for its pinned Vitest-state exemption, while `win32-process` follows `sandbox-windows-acl` for its pinned Koffi exemption. + ### The roster, reconciled entry by entry A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited: @@ -30,6 +34,7 @@ A suite contributes to coverage exactly when it executes measured files in-proce | All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | | tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | | `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts`, `scripts/translation-pairing-merge.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | +| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | None — its package-source imports and the complete bundle sweep run in a spawned Node process | The Web Worker runtime's in-process unit suites carry its source coverage | ### Membership contract @@ -49,15 +54,22 @@ Coverage-result invariance therefore does not rest on humans maintaining the ros - **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. - **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. - **Cross-runner sharding (`--shard` + blob merge).** Rejected because a matrix, artifact pipeline, and merge job would add a second workflow topology. The selected [in-job partitioning](2026-08-18-in-job-partitioned-coverage.md) uses Vitest shards only as local single-worker processes inside the existing job. +- **Keep the transform corpus in one Node process.** Rejected because its serial loader becomes the Windows heavy gate's longest tail under host contention. Eight local children retain the same file set, per-file oracle, loader-sensitive affinities, and one blocking Vitest verdict. - **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. ## Verification Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction. +The Web Worker corpus entry is pinned by a partitioned aggregate that runs all 15,250 tests and reports 100% for 45,959 statements, 28,116 branches, 9,781 functions, and 40,550 lines. A focused instrumented corpus run records no package source from its child process; the paired list check proves the spec is absent from the instrumented inventory and present in the uninstrumented inventory. + +The eight-child corpus run checks the same 239 native Windows bundles with 234 exact export matches, four pinned loader exemptions, one sentinel refusal, and no drift. The ARM64 VM measures 25.44 seconds for the sharded Vitest path versus 29.59 seconds for the unsharded checker; the complete x64 job remains the contended-host timing proof. + ## Consequences - The exempt suites execute without adding instrumentation cost to the thresholded gate; partitioned wall-clock measurements belong to the [in-job partitioning decision](2026-08-18-in-job-partitioned-coverage.md). +- Native Windows schedules the exempt suites after instrumented coverage and overlaps them with observational checks; Linux retains the parallel coverage split. +- The corpus suite uses up to eight non-empty child Node loaders but emits one blocking test result; its affinity roster is part of the exemption oracle and must move with affected bundles. - `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. - Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. - The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md index 7e519f44c8..cefade0805 100644 --- a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -10,6 +10,8 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。 +Web Worker 转换语料库在原生 Windows 上暴露了同一类浪费:`transform-corpus.spec.ts` 在一个 442 秒的覆盖率分区中占用 279 秒,而其余七个分区在 110–161 秒内完成。它的真实检查器只在 spawn 的 Node 子进程中运行包源码,处于父 Vitest worker 的 v8 覆盖率会话之外,因此这个慢分区没有从该工作中产生任何阈值数据。 + ## Decision `ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税: @@ -17,10 +19,12 @@ CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试 - **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 - **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 -Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。 +Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)。其合并报告承担相同的阈值证明;豁免门禁及其成员资格规则保持不变。Linux 会让 4 个分区子进程、2 个豁免 worker 与最多 8 个语料库子进程重叠,因此该通道变慢时应先检查这组并发。原生 Windows 在插桩报告合并后运行豁免门禁,同时让轻量观测性清单与豁免工作重叠,因此完整语料库子进程不会与 16 个覆盖率进程争用资源。Oxlint 约定套件会原子发布满足源码扫描要求的包内临时探针,并把只属于脚本的探针对 glob 发现隐藏。 `scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格约定与 filter/exclude 配对,防止两侧漂移。 +`transform-corpus.spec.ts` 只发现一次完整的已构建 bundle 集合,把每条路径恰好分配给最多 8 个非空 Node loader 子进程之一,并在启动前断言分片并集。`client-runtime` 会为固定的 Vitest 状态豁免跟在 `acp-snapshot` 之后,`win32-process` 则会为固定的 Koffi 豁免跟在 `sandbox-windows-acl` 之后。 + ### 豁免名单与逐项对账 一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对: @@ -30,6 +34,7 @@ Linux 覆盖率 CI 与原生 Windows CI 在插桩门禁内部使用 [job 内分 | typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | | 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | | `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts`、`scripts/translation-pairing-merge.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | +| `packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts` | 无——包源码 import 与完整 bundle 扫描都在 spawn 的 Node 子进程中运行 | Web Worker runtime 的进程内单元套件承担其源码覆盖率 | ### 成员资格约定 @@ -49,15 +54,22 @@ per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默 - **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 - **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 - **跨 runner 分片(`--shard` + blob 合并)。** 不予采用,因为 matrix、产物流水线和合并 job 会引入第二套工作流拓扑。所选的 [job 内分区](2026-08-18-in-job-partitioned-coverage.zh.md)只把 Vitest shard 用作既有 job 内的本地单 worker 进程。 +- **让转换语料库保留在一个 Node 进程中。** 不予采用,因为串行 loader 在宿主争用下成为 Windows 重型门禁的最长尾部。八个本地子进程保留相同文件集、逐文件判定器、对 loader 敏感的亲和顺序,以及一个阻断性 Vitest 判定。 - **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 ## Verification CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。 +Web Worker 语料库条目由分区聚合固定:它执行全部 15,250 个测试,并对 45,959 条语句、28,116 个分支、9,781 个函数和 40,550 行报告 100%。聚焦的插桩语料库运行不会记录其子进程中的包源码;配对名单检查证明该 spec 不在插桩清单中,但存在于无插桩清单中。 + +八子进程语料库运行检查相同的 239 个原生 Windows bundle,得到 234 个精确 export 匹配、四个固定 loader 豁免、一次 sentinel 拒绝和零漂移。ARM64 虚拟机上,分片 Vitest 路径耗时 25.44 秒,未分片检查器耗时 29.59 秒;完整 x64 job 仍负责证明宿主争用下的耗时。 + ## Consequences - 豁免套件在执行时不会向阈值门禁叠加插桩开销;分区墙钟数据由 [job 内分区决策](2026-08-18-in-job-partitioned-coverage.zh.md)负责记录。 +- 原生 Windows 在插桩覆盖率后调度豁免套件,并让它们与观测性检查重叠;Linux 保留并行覆盖率拆分。 +- 语料库套件使用最多 8 个非空 Node loader 子进程,但只产生一个阻断性测试结果;其亲和名单属于豁免判定器,受影响 bundle 移动时必须同步更新。 - `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 - 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 - 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index 04138e9332..d01431c0f8 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.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/process/2026-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: 40f330d0feaca519bdc683e1992597b4be46cd8e -2026-08-03-package-anchored-subsystem-pages.zh.md: 25f96e7411728e5cb49cd663dc5cd087688b068c +2026-08-03-package-anchored-subsystem-pages.md: e5337fba0c9a69dd786fb156572e978f87435b9b +2026-08-03-package-anchored-subsystem-pages.zh.md: 25afe84faa43af59d4366b5cc0b6c2d2ad4da1ed diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index 40f330d0fe..e5337fba0c 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,9 +14,11 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages//README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages//README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. A group that declares no standalone subsystem reference is instead classified with a non-empty rationale in `GROUPS_WITHOUT_SUBSYSTEM_PAGE`. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. -The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. +`verify-subsystem-pages` discovers groups from both group READMEs and child package manifests. It rejects a missing group README, a group with neither a reader-visible direct link to one English file under `docs/subsystems/` nor an explicit exemption, a blank or orphaned exemption, an exempt group that gains a link, and a link whose page is absent; code, comments, images, nested paths, and traversal do not satisfy ownership. The check runs as an independent `doc-sync` leaf, so adding a package group cannot silently omit its documentation owner. + +The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so an added or merged page cannot silently miss the index. ## Alternatives considered @@ -29,6 +31,7 @@ The [subsystems README](../../../../docs/subsystems/README.md) indexes every pag ## Consequences - Which page documents a type is predictable from `packages//`; the subsystems README is a complete index enforced by test. +- Every package group makes its subsystem owner or justified absence reviewable, and `verify-subsystem-pages` rejects unclassified additions and stale exemptions. - Generated signature footers link the agent ownership vocabulary instead of silently exempting it. - `verify-type-equiv`'s 1:1 manifest keeps each paste single-homed; the duplicate `SessionEvent` paste is gone. - The [original catalog note](2026-06-20-core-data-structures-catalog.md) remains the owner of the `ts type-equiv` drift-gate mechanism; only its page-scoping rule is superseded here. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 25f96e7411..25afe84faa 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,9 +14,11 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给确实仅用于服务内部或来自 vendored 代码的类型结构。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.zh.md);core.md 概括并链接)。 -每个 `packages//README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。 +每个 `packages//README.md` 配对都是统一形状的精简入口:一段先说明「为什么」的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向对应子系统页面的收尾链接。未声明独立子系统参考资料的分组会在 `GROUPS_WITHOUT_SUBSYSTEM_PAGE` 中附上非空理由。如果承载关键信息的正文超出这一结构所能容纳的范围,就将其迁移到对应的子系统页面,而非删除。 -[子系统 README](../../../../docs/subsystems/README.zh.md) 在中英文两侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每个页面对应一个表格行,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 +`verify-subsystem-pages` 同时从分组 README 和子包 manifest(元数据清单)发现分组。它会拒绝缺少分组 README、分组既没有面向读者且直接指向 `docs/subsystems/` 下某一个英文文件的链接也没有显式豁免、豁免为空或成为孤立项、已豁免分组新增链接,以及链接指向的页面不存在;代码、注释、图片、嵌套路径和路径穿越都不能满足所有权声明。该检查作为独立的 `doc-sync`(文档同步)叶节点运行,因此新增包分组时不能悄悄遗漏其文档拥有方。 + +[子系统 README](../../../../docs/subsystems/README.zh.md) 在中英文两侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每个页面对应一个表格行,因此新增或合并吸收的页面无法悄悄缺席索引。 ## 考虑过的替代方案 @@ -29,6 +31,7 @@ Status: implemented ## 后果 - 哪一页记录某类型可由 `packages//` 预测;子系统 README 是由测试强制的完整索引。 +- 每个包分组都会将其子系统拥有方或合理的缺席原因暴露给评审,且 `verify-subsystem-pages` 会拒绝未分类的新增项和陈旧豁免。 - 生成的签名页脚链接 agent 所有权词汇,而不是静默豁免。 - `verify-type-equiv` 的 1:1 manifest(元数据清单)保证每个粘贴单一归属;重复的 `SessionEvent` 粘贴已移除。 -- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移门禁机制;此处仅取代其页面范围界定规则。 +- [原目录 Agent Note](2026-06-20-core-data-structures-catalog.zh.md) 仍拥有 `ts type-equiv` 漂移检查机制;此处仅取代其页面范围界定规则。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml index 56c6d31682..f6b59921ba 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.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/process/2026-08-06-in-repository-landlock-release.md -2026-08-06-in-repository-landlock-release.md: 82b21cc0c30338ad11583797f011794b8dbcc90c -2026-08-06-in-repository-landlock-release.zh.md: 554967fbce454fc9a45b54d735f485006f9dee51 +2026-08-06-in-repository-landlock-release.md: 25c31c3cdcc57cbcc8bd09b82ca24898ebca8268 +2026-08-06-in-repository-landlock-release.zh.md: 71cd2b7fe342003bc458e98ee3d2e25496b535bb diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md index 82b21cc0c3..25c31c3cdc 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -22,7 +22,7 @@ The public npm boundary is three organization-owned packages with one launcher-f The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. -The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. +The sandbox packed-install rehearsal does not permit the npm registry to supply the launcher. It derives the harness closure transitively from current workspace `dependencies`, `optionalDependencies`, and required `peerDependencies`; the native family stays separate because its mode-preserving pack script supplies the entry and matching platform package. The rehearsal installs those local tarballs into an external plain-Node consumer and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md index 554967fbce..71cd2b7fe3 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -22,7 +22,7 @@ Status: implemented 主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 -沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 +沙箱打包安装演练不允许 npm 注册表提供启动器。它会根据当前 workspace 的 `dependencies`、`optionalDependencies` 与必需 `peerDependencies` 递归推导 harness 闭包;原生包家族保持独立,因为保留文件模式的打包脚本会提供入口包和匹配平台包。演练把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 5fb38dbf24..ea0f2ca087 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: d4883cf1363a33a444f1172829149c0c41f21c10 -2026-08-08-native-windows-pull-request-ci.zh.md: 0ba9629e873d82aba33a2cbad9e46e264ffe4312 +2026-08-08-native-windows-pull-request-ci.md: d3b37bdcc19b7b06aee2aa4a067cfd317617ab67 +2026-08-08-native-windows-pull-request-ci.zh.md: 01edfece893d1d6f4cbb14a7d061e372313327f3 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index d4883cf136..d3b37bdcc1 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md) ## Problem -The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. +The pull-request Windows verdict needs both a fast win32 toolchain signal and a real Windows-kernel result. Wine provides the fast signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result. A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator. @@ -14,13 +14,13 @@ A coverage audit found that stale branch state had restored temporary exclusions The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. +Every pull request also starts a separate `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned `@pnpm/exe` through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. Package scripts therefore expose `pnpm.exe` through `npm_execpath`, making the complete inventory exercise shell-free package-manager re-entry on Windows. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. -The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. +The native job retains its own unmasked result. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes that result a dependency of `all checks passed`; this note owns the job's execution topology and complete inventory. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. Static, documentation, package, built-artifact, lint, and snapshot inventories run in the same job as observational gates: their failures remain visible without changing the native aggregate result because Linux owns their blocking verdict. -The 16-core lane admits four concurrent outer gates. Workspace build, production-site validation, and instrumented coverage start immediately. Exempt-heavy coverage waits for the build to pass, so its temporary Oxlint contract probes cannot race source compilation. Every observational gate waits for both coverage gates to settle, regardless of outcome, before entering an available slot; its own `needs` edges still require their predecessors to pass. This also keeps later static gates that create temporary contract files from racing either coverage scan. [In-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) uses eight single-worker shards, while the exempt-heavy gate receives two workers from `DSH_COVERAGE_MAX_WORKERS=6`. The initial phase therefore has about ten active execution units; after build, starting exempt-heavy while build leaves keeps the peak near eleven when site and instrumented coverage are still running. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane admits eight concurrent outer gates. Workspace build, production-site validation, and sixteen-process instrumented coverage start immediately. Exempt-heavy coverage needs the build and waits for the merged coverage verdict, so its four Vitest workers and up to eight corpus children do not compete with the partition phase. The lightweight observational inventory also waits for coverage, then overlaps the exempt work; temporary package probes are atomically published with scanner-valid contents, while script-only probes use hidden filenames. `publint` is capped at eight workers when the observational inventory starts. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 120-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds, but used the whole host before the exempt, build, and site work was counted; eight shards deliberately trade some latency for headroom. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits, while separate single-worker child processes retain process isolation. Sixteen-shard samples and the final hosted run complete instrumented coverage in 112.66–131.33 seconds; the job gives that phase the host before starting exempt work. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. @@ -36,8 +36,6 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Alternatives considered -**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path. - **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. **Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism. @@ -50,7 +48,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result. +Wine preserves a fast early signal and its stable job identity. [The aggregate-dependency decision](2026-08-22-native-windows-blocks-pull-request-aggregate.md) makes `all checks passed` wait for both Wine and native Windows, so branch protection consumes their combined verdict through one stable required check. Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 0ba9629e87..01edfece89 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 +拉取请求的 Windows 判定同时需要快速的 win32 工具链信号与真实 Windows 内核结果。Wine 提供快速信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间,每个拉取请求分支头还需要自动取得真实 Windows 内核结果。 覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。 @@ -14,13 +14,13 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个单独的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 `@pnpm/exe`,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。因此 package script 会通过 `npm_execpath` 暴露 `pnpm.exe`,让完整清单在 Windows 上覆盖无 shell 的包管理器再进入。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 -原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 +原生作业保留自身未被掩盖的结果。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让该结果成为 `all checks passed` 的依赖项;本文负责该作业的执行拓扑与完整清单。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。静态检查、文档、包、构建产物、lint 与快照清单在同一作业内作为观测性门禁运行;其失败保持可见,但不会改变原生聚合结果,因为这些检查的阻断性判定由 Linux 负责。 -16 核通道最多同时运行 4 道外层门禁。工作区构建、生产网站验证与插桩覆盖率会立即启动。豁免重型覆盖率等待构建通过后再启动,使其临时 Oxlint 约定探针不会与源码编译竞态。每道观测性门禁只等待两道覆盖率门禁以任意结果结算后再进入可用槽位;各门禁自身的 `needs` 边仍要求前置门禁通过。这也使随后创建临时约定文件的静态门禁不会与任一覆盖率扫描竞态。[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)使用 8 个单 worker 分片,豁免重型门禁则从 `DSH_COVERAGE_MAX_WORKERS=6` 获得 2 个 worker。因此初始阶段约有 10 个活动执行单元;构建结束并启动豁免重型门禁后,如果网站与插桩覆盖率仍在运行,峰值约为 11 个。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道最多同时运行 8 道外层门禁。工作区构建、生产网站验证与 16 进程插桩覆盖率会立即启动。豁免重型覆盖率依赖构建并等待覆盖率报告合并,因此其 4 个 Vitest worker 与最多 8 个语料库子进程不会和分区阶段争用资源。轻量观测性清单同样等待覆盖率,随后与豁免工作重叠;包内临时探针会以满足源码扫描要求的完整内容原子发布,只属于脚本的探针则使用隐藏文件名。观测性清单启动时,`publint` 最多使用 8 个 worker。每个 Vitest 项目都使用 fork worker,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享 worker 中复现。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture(测试前置数据)可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,120 分钟的 job 截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒,但还未计入豁免、构建与网站工作就已经占满整台宿主;8 个分片刻意用部分延迟换取余量。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出,而相互独立的单 worker 子进程保留进程隔离。16 分片样本与最终托管运行会在 112.66–131.33 秒内完成插桩覆盖率;作业会先把宿主资源交给该阶段,再启动豁免工作。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 @@ -36,8 +36,6 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 曾考虑的替代方案 -**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。 - **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 **将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。 @@ -50,7 +48,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。 +Wine 保留快速的早期信号与稳定作业身份。[聚合依赖决策](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md)让 `all checks passed` 同时等待 Wine 与原生 Windows,因此分支保护通过一个稳定的必需检查采用二者的合并判定。 尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建,在标准镜像上明显更慢;但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml index ba1eda2a11..fa29e34aca 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.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/process/2026-08-09-committed-artifact-citations.md -2026-08-09-committed-artifact-citations.md: 044f7683d51ebf2038f56d2b5a27755ecc9be6d5 -2026-08-09-committed-artifact-citations.zh.md: 620a99195f9a9c0337b1145d87ba1f8a042eb430 +2026-08-09-committed-artifact-citations.md: a84b25545b97b6df62b3d51da895b4c7f8887ccf +2026-08-09-committed-artifact-citations.zh.md: 8cff257fd22d449d965067343e6aed4413b8eae7 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md index 044f7683d5..a84b25545b 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md @@ -17,6 +17,8 @@ Durable prose — comments, JSDoc, docs, notes, test comments and titles — cit - Fixed regressions are pinned as present-tense counterfactuals ("without X, Y happens"; "a naive X would…"), never as repo history ("used to Y"). - Implemented notes state shipped reality: a "deferred to a later PR" claim whose target shipped names the shipped note instead. - Recorded fixtures, snapshots, and archived notes are exempt: recorded model output and sealed history keep their original voice. Inside a note's change-story sections, a historical stage name ("the first cut shipped X") is current-state-safe; indexical stamps ("this cut") stay banned everywhere. +- Recall probes use lexical boundaries and are calibrated against a known positive and a near-miss negative. Authoring-language probes target the opposite-language surface instead of treating the complete Chinese corpus as untranslated residue. +- Owner-first edits trace every generated consumer. Verbatim code fences are copied byte-for-byte across bilingual pairs; model- or user-visible wording changes only with its owning behavior evidence, otherwise the audit leaves it unchanged and reports the deferral. One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot examples for deciding what to keep or delete. diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md index 620a99195f..8cff257fd2 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md @@ -17,6 +17,8 @@ Status: implemented - 已修复的回归以现在时反事实句固定下来(「没有 X 就会发生 Y」、「朴素的 X 会……」),绝不写成仓库历史(「过去曾 Y」)。 - 已实现的 Agent Note 陈述已交付的现实:「推迟到后续 PR」的说法若其目标已经交付,就改为点名那篇已交付的 note。 - 已录制的 fixture(测试前置数据)、快照与已归档的 Agent Note 不受此约束:已录制的模型输出与封存的历史保持原有行文。在 note 的变更故事段落内,历史阶段名称(「首版交付了 X」)符合只描述当前状态的要求;「this cut」这类指示当前版本的标记在任何地方都仍被禁止。 +- 召回检索使用词法边界,并以一个已知正例和一个近似反例校准。写作语言残留检索仅在另一语言的行文表面运行,不把整个中文语料库当作未翻译残留。 +- 先改归属源时追踪每个生成消费方。围栏代码块在双语配对中逐字节复制;模型或用户可见的文字只有在归属场景的行为证据随同更新时才改,否则审计保持原文并报告推迟项。 一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts`、`scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语,改后重新生成)、type-equiv 源码中的 JSDoc(改后重新同步到文档页)以及双语对侧文件(改后重新记录配对)。[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及用于判断保留或删除内容的少样本示例。 diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml index a85d782857..5892d118ec 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.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/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md -2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: b7df5403ea11ff8ba32be0f9bede5a32d5bcd6ee -2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 8a45d452bfd98c1fe87a9a42bd4930273d034ca8 +2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: 887a3666501a97e0930fe7eb4282603f537c17e8 +2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 0202be5c22c75ff4feec225cdff12b772d64d1cf diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md index b7df5403ea..887a366650 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md @@ -16,6 +16,8 @@ Maintained prose names the exact actor, action, source, event, field, file, or p The rule applies to Markdown, READMEs, active Agent Notes, JSDoc and comments, prompts, diagnostics, and user-visible strings. An audit judges each sentence separately; it does not replace a term across the repository with one preferred synonym. The edited sentence preserves actor, action, conditions, order, modality, exceptions, ownership, failure behavior, and consequences. +Comments retain only facts that nearby code cannot express. Documentation stays at its owning level and omits private control flow and rare implementation cases unless they change supported behavior, safe use, compatibility, data integrity, security, or another maintained contract. The [simplification workflow](../../../skills/dsh-find-simplifications/SKILL.md) applies this rule while surveying code and prose together. + Exact code identifiers, public APIs, durable fields, protocol members, type names, headings with external references, and filenames stay unchanged unless a coordinated contract rename is independently required. Surrounding prose explains their fields or behavior directly. Generated documents and catalogs update from their owning source. Before using `contract`, `boundary`, or `shape`, writers check whether the sentence means a more specific rule, operation, data structure, field set, validation point, timing point, API, type, or failure condition. `Contract` remains correct for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. `Boundary` remains correct for a literal security, trust, wire, process, serialization, transaction, or lifecycle division. `Shape` remains correct when the structural form itself is the subject and no narrower term such as fields, schema, type, union variant, file layout, or export form states the fact. Code and API names containing these words remain unchanged unless a separate coordinated rename is required. diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md index 8a45d452bf..0202be5c22 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md @@ -16,6 +16,8 @@ Status: implemented 该规则适用于 Markdown、README、活跃 Agent Note、JSDoc 与注释、提示词、诊断信息和用户可见字符串。审查会分别判断每个句子,不会在整个仓库中用一个偏好的近义词统一替换某个术语。编辑后的句子保留执行者、动作、条件、顺序、情态、例外、归属、失败行为和后果。 +注释只保留附近代码无法表达的事实。文档停留在内容归属的层级;除非私有控制流或罕见实现情形会改变受支持行为、安全使用、兼容性、数据完整性、安全保障或另一项持续维护的约定,否则文档不描述它们。[简化工作流](../../../skills/dsh-find-simplifications/SKILL.md)在同时巡查代码与行文时应用此规则。 + 除非另一项独立需求明确要求协调重命名约定,否则确切的代码标识符、公开 API、持久字段、协议成员、类型名、带有外部引用的标题和文件名均保持不变。它们周围的行文直接说明其字段或行为。生成的文档和目录在维护它们的源文件修改后更新。 使用 `contract`、`boundary` 或 `shape` 之前,写作者要确认句子是否实际指更具体的规则、操作、数据结构、字段集合、校验点、时间点、API、类型或失败条件。调用方、被调用方、实现方、提供方、生产方或消费方依赖的前置条件、后置条件、不变量、兼容性承诺及其他义务仍可准确称为 `contract`。真实的安全、信任、wire、进程、序列化、事务或生命周期分界仍可准确称为 `boundary`。当结构形式本身就是主题,且字段、schema、类型、联合变体、文件布局或导出形式等更窄的词无法说明事实时,仍可使用 `shape`。除非另一项独立需求要求协调重命名,否则包含这些词的代码和 API 名称保持不变。 diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml index d431553f1f..4d85c63d60 100644 --- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.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/process/2026-08-09-md-fragment-anchor-gate.md -2026-08-09-md-fragment-anchor-gate.md: e02a917bedd9649a2326e3fb1f53072ac05c88a8 -2026-08-09-md-fragment-anchor-gate.zh.md: 09cd2326fd11457d62cab77fe566b5d71ffc8e7e +2026-08-09-md-fragment-anchor-gate.md: a5c4029f5b11f464e09356915d5d2f9134bee616 +2026-08-09-md-fragment-anchor-gate.zh.md: 50c941b3e2d43e4991ee3a748b1b50b36611e34d diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md index e02a917bed..a5c4029f5b 100644 --- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md @@ -14,7 +14,7 @@ English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md) The slug function differs from `gen-cordis-catalog`'s region-anchor slugger (which drops underscores): the generator's headings are always reachable through its explicit `` anchors, so the two need not share one rule. Chinese pair sides follow the existing repository convention (`docs/glossary.zh.md`, `docs/cordis-primer.zh.md`): keep the English fragment in the link and place an explicit `` before the Chinese heading, so both language sides expose identical anchors. -The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; it survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown (today's three all render into scanned pages, so the gate covers them through the committed output). +The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; it survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown (the three scanned references all render into scanned pages, so the gate covers them through the committed output). ## Verification diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md index 09cd2326fd..50c941b3e2 100644 --- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md @@ -14,7 +14,7 @@ Status: implemented slug 函数与 `gen-cordis-catalog` 的区块锚点 slugger 不同(后者丢弃下划线):生成器的标题总能通过其显式 `` 锚点到达,两者无需共享一条规则。中文侧沿用既有语料惯例(`docs/glossary.zh.md`、`docs/cordis-primer.zh.md`):链接保留英文 fragment,在中文标题前放置显式 ``,使两个语言侧暴露相同的锚点。 -15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时约定改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再要求为 Markdown 链接手工 grep 锚点;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(当下三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。 +15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时约定改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再要求为 Markdown 链接手工 grep 锚点;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(扫描到的三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。 ## 验证 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index d31b17817e..b454ac5682 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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/process/2026-08-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: 870db08e1d59ad7840fa9acf822915f83ecbd31b -2026-08-11-python-publication-workflow.zh.md: 0b2b4a71b909a510bc5a7f52132dbb0ba2bf3e67 +2026-08-11-python-publication-workflow.md: db346dfb96d1657e732c72a3f7a3ca74f92a947a +2026-08-11-python-publication-workflow.zh.md: 17b9b14dd16d85301796a38bb64c464c94a8ab9a diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index 870db08e1d..db346dfb96 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -10,7 +10,7 @@ The Python SDK comprises one platform-independent client wheel and three native ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to pull requests labeled `python-release-dry-run` and to manual runs with `publish=false`. Both paths call the native wheel builder for all three platforms, install the Linux release set on Python 3.10 and 3.14, download the four resulting artifacts, verify their exact filenames and package metadata, enforce PyPI's default per-file size limit, record SHA-256 hashes, and retain one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and pull request events cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all three platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the four resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md index 0b2b4a71b9..17b9b14dd1 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -10,7 +10,7 @@ Python SDK 由一个平台无关的客户端 wheel 包和三个原生运行时 w ## 决策 -GitHub 的 `Release (Python)` 工作流为带有 `python-release-dry-run` 标签的拉取请求和设置 `publish=false` 的手动运行提供无凭据验证。两条路径都会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,拉取请求事件无法进入任何发布作业。 +GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml deleted file mode 100644 index ef5f11a4f8..0000000000 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md -2026-08-12-documentation-site-navigation-and-chrome.md: 03cd44b94f853725da33800e8c89886b1a657a0b -2026-08-12-documentation-site-navigation-and-chrome.zh.md: d0972f909e648278cb3cecb7788705b228f4b675 diff --git a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.i18n.yaml index a013e9705b..11d862cd85 100644 --- a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.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/process/2026-08-13-public-vendor-and-native-sequences.md -2026-08-13-public-vendor-and-native-sequences.md: ada7c3bcbe5feb288d67fe8e7ee7204dda6b4597 -2026-08-13-public-vendor-and-native-sequences.zh.md: ba9381dda94c09424d9218d3982bfdb128225e5d +2026-08-13-public-vendor-and-native-sequences.md: 2b2b8039f8233ff38dc17aa39368a6c6c7282bda +2026-08-13-public-vendor-and-native-sequences.zh.md: 8ee806f0d49d2038f30e396371a4f09977a7cc57 diff --git a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.md b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.md index ada7c3bcbe..2b2b8039f8 100644 --- a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.md +++ b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.md @@ -30,7 +30,7 @@ Access is a property of the package, not of a version: the twelve packages alrea ## Alternatives considered -**Flip the whole scope public at once.** Rejected for now: it would make the next dsh release public as a side effect of a manifest change rather than a deliberate release decision. Opening the two dependency sequences first is the order that keeps every published package installable at each step, and it is the precondition for opening dsh whenever that is decided. +**Flip the whole scope public at once.** Rejected: it would make a dsh release public as a side effect of a manifest change rather than a deliberate release decision. Opening the two dependency sequences first keeps every published package installable at each step and is a precondition for opening dsh. **Keep everything restricted and grant a read-only team instead.** `npm access grant read-only ` is per-package with no scope wildcard, so covering the set means one grant per package plus a standing reconciliation job for every package added afterwards. It also only reaches organization members, which does not serve an installable public artifact. diff --git a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.zh.md b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.zh.md index ba9381dda9..8ee806f0d4 100644 --- a/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-13-public-vendor-and-native-sequences.zh.md @@ -30,7 +30,7 @@ access 是包的属性、不是版本的属性:已经以 restricted 发布的这 ## Alternatives considered -**一次性把整个 scope 改成 public。** 暂不采用:那会让下一次 dsh 发布因为一次 manifest 改动而顺带变成公开,而不是出自一个刻意的发布决定。先公开这两条依赖序列,是能让每一步的已发布包都保持可安装的顺序,也是将来决定公开 dsh 时的前置条件。 +**一次性把整个 scope 改成 public。**不予采纳:这会让一次 dsh 发布因 manifest 改动而顺带公开,而不是来自刻意的发布决定。先公开两条依赖序列,能让每一步的已发布包都保持可安装,也是公开 dsh 的前置条件。 **全部保持受限,改为授予一个只读 team。** `npm access grant read-only <包>` 是逐包的、没有 scope 通配,覆盖全集意味着每个包一次 grant,外加一个为后续新增包长期补齐的对账任务。它也只能覆盖组织成员,无法服务一个可安装的公开产物。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index d517367d9d..d8ee642db9 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: f86c2dffb6d3d30fdccfa445c57043c5217e439b -2026-08-18-in-job-partitioned-coverage.zh.md: 62bb77511dd2c28b31e470288ff5c906dd346aeb +2026-08-18-in-job-partitioned-coverage.md: 8d8684b6299f4c2dc359ced09048d0b6acb9ed42 +2026-08-18-in-job-partitioned-coverage.zh.md: 4781b5e0abb8dc4dde8004b08f5a5f105ef50b80 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index f86c2dffb6..8d8684b629 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -12,13 +12,13 @@ The optimization must retain every test and the merged per-file 100% thresholds. ## Decision -The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 8; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate beside the instrumented work. +The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Linux coverage CI fixes `DSH_COVERAGE_PARTITIONS=4`, while native Windows fixes it at 16; no elapsed-time trigger changes either count while a run is in progress. The [coverage-exempt heavy suite](2026-07-31-coverage-exempt-heavy-suites.md) remains a separate uninstrumented gate. When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker and one `--shard=/` option. Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process. The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. Build, production-site validation, and instrumented coverage start immediately; exempt-heavy coverage starts only after build passes, preventing its temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Build, production-site validation, and instrumented coverage start immediately on native Windows. The exempt gate needs the build and waits for instrumented coverage to settle, so its full-corpus children and temporary Oxlint probes do not compete with the sixteen partitions; it then receives four workers from the budget of 12. The observational inventory also waits for instrumented coverage, then overlaps the exempt gate within an eight-worker outer budget. Ordering uses `after`, so both groups still run after an instrumented failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -28,9 +28,11 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al ## Verification -`scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. +`scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures. -Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds, but the sixteen-way schedule could put more than twenty active execution units beside build and exempt coverage on a 16-core runner. Eight partitions keep separate-process isolation while accepting a longer feedback path for a materially lower peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds. Sixteen is the fixed Windows count. The exempt gate waits for their merged verdict, so the partition phase overlaps only build and production-site validation: at most eighteen active execution units on a 16-core runner, rather than adding exempt workers to that peak. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. + +The native ARM64 VM runs the full transform corpus in 29.59 seconds without coverage partitions and in 25.44 seconds through the eight-child Vitest path. A concurrent self-hosted x64 job stretched the former serial test to 279.13 seconds while one instrumented partition reached 442.45 seconds. The Windows graph separates the partition and exempt phases before applying its fixed sixteen-way coverage fan-out. ## Alternatives considered @@ -38,14 +40,18 @@ Completed native Windows comparisons measured two partitions near 405 seconds an **Raise the Vitest worker count inside one instrumented process.** Rejected because completed Windows trials at higher fan-out exposed worker exits, fixture instability, and Node 24 CJS lexer failures. Separate single-worker processes preserve isolation while still executing the selected partitions concurrently. -**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's eight-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. +**Use one partition count on every host.** Rejected because Linux's four-process run and Windows's sixteen-process run have different startup costs and resource ceilings. Each fixed configuration requires its own completed end-to-end evidence. **Apply thresholds independently in each partition.** Rejected because every partition intentionally sees only part of the suite and would report false uncovered files. Threshold ownership belongs to the merged report. +**Overlap the Windows exempt gate with instrumented partitions.** Rejected because the full-corpus checker is fast in isolation but multiplies under partition contention. The post-coverage phase uses available workers for the exempt and observational checks without changing either verdict. + ## Consequences Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. +Windows uses two resource phases inside the same job: sixteen isolated coverage processes through the merged threshold verdict, then the four-worker exempt gate beside lightweight observational checks. + Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 62bb77511d..4781b5e0ab 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 8;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁与插桩工作并排运行。 +普通的 `pnpm run test:coverage` 命令仍只启动一次 Vitest。Linux 覆盖率 CI 将 `DSH_COVERAGE_PARTITIONS` 固定为 4,原生 Windows 则固定为 16;运行期间不会由任何耗时触发器改变这两个数量。[覆盖率豁免重型套件](2026-07-31-coverage-exempt-heavy-suites.zh.md)仍作为独立的无插桩门禁。 启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker,并各自接收一个 `--shard=/` 选项。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。构建、生产网站验证与插桩覆盖率会立即启动;豁免重型覆盖率只在构建通过后启动,避免其临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 上的构建、生产网站验证与插桩覆盖率会立即启动。豁免门禁要求构建通过,并等待插桩覆盖率结算,因此其完整语料库子进程和临时 Oxlint 探针不会与十六个分区争用资源;随后它从 12 的预算中获得 4 个 worker。观测性清单也等待插桩覆盖率,然后在八 worker 的外层预算内与豁免门禁重叠。该顺序使用 `after`,因此插桩失败后两组检查仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -28,9 +28,11 @@ Status: implemented ## 验证 -`scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。 +`scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。 -已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒,但 16 路调度与构建、豁免覆盖率并行时,会在 16 核运行器上形成超过 20 个活动执行单元。8 个分区继续保留独立进程隔离,同时接受更长的反馈路径,以显著降低峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒。Windows 固定使用 16 个分区。豁免门禁等待其合并判定,因此分区阶段只与构建和生产网站验证重叠:16 核运行器上最多有 18 个活动执行单元,不会再把豁免 worker 加入该峰值。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 + +原生 ARM64 虚拟机在没有覆盖率分区时用 29.59 秒运行完整转换语料库,通过八子进程 Vitest 路径时用 25.44 秒。一个并发运行的自托管 x64 job 把此前的串行测试拉长到 279.13 秒,同时一个插桩分区达到 442.45 秒。Windows 门禁图先分离分区阶段与豁免阶段,再应用固定的 16 路覆盖率扇出。 ## 曾考虑的替代方案 @@ -38,14 +40,18 @@ Status: implemented **提高单个插桩进程内的 Vitest worker 数。** 不予采用,因为已完成的 Windows 高扇出试验暴露了 worker 退出、fixture(测试前置数据)不稳定和 Node 24 CJS lexer 故障。相互独立的单 worker 进程既保留隔离,也能让所选分区并发执行。 -**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 8 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 +**在每种宿主上使用相同的分区数量。** 不予采用,因为 Linux 的 4 进程运行与 Windows 的 16 进程运行具有不同的启动成本与资源上限。每种固定配置都必须取得自己的端到端完整证据。 **在每个分区内独立应用阈值。** 不予采用,因为每个分区有意只看到套件的一部分,会误报未覆盖文件。阈值归合并报告所有。 +**让 Windows 豁免门禁与插桩分区重叠。** 不予采用,因为完整语料库检查器在独立运行时很快,却会在分区争用下成倍变慢。覆盖率后的阶段把可用 worker 用于豁免检查与观测性检查,不改变任何一项判定。 + ## 后果 每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 +Windows 在同一个 job 内使用两个资源阶段:十六个隔离的覆盖率进程先产出合并阈值判定,随后四 worker 的豁免门禁与轻量观测性检查并排运行。 + 未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml similarity index 57% rename from .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml rename to .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml index 8e355b809e..b6cb38880f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 -2026-08-18-request-image-payload-bound.zh.md: 7cdf6bb768251cb792b6d590fafa094646e77ada +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md +2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2 +2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md new file mode 100644 index 0000000000..5b45657d13 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -0,0 +1,37 @@ +# Agent Note: Raw-Markdown twins and llms.txt on the documentation site + +Status: implemented + +English | [中文](2026-08-20-doc-site-raw-markdown-twins.zh.md) + +## Problem + +The documentation site serves rendered HTML only, so an agent reading the docs has to scrape VitePress markup or fall back to the repository, where links and image references follow source layout rather than public routes. Claude's platform documentation set the convention this feature adopts: append `.md` to any page URL for the page as raw Markdown, with a root `llms.txt` as the agent-facing index. [The site projection](2026-07-13-documentation-site-projection.md) rewrites every page's links for the public site already, so the gap was serving that projection as plain Markdown. + +## Decision + +`vitepress build` ends by emitting a raw-Markdown twin of every published route into the build output. `emitRawMarkdownPages` runs the same manifest-and-projector pass that fills `website/.generated/`, but writes `/` with raw page content: no `editSource`/`outline` projection frontmatter, no locale-home truncation — frontmatter is VitePress rendering configuration, so the twin drops it and keeps the body — and the same repository-chrome stripping as the rendered site. Referenced images are copied beside the twins. + +One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure. + +An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission. + +`llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label]():
` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point. + +The dev server serves the same surface for navigations and header-less clients. A middleware in the doc-projector plugin projects `.md` requests from their canonical sources per hit and generates `llms.txt` on demand, so `docs:dev` matches production without a rebuild; an in-page `fetch()` (`Sec-Fetch-Dest: empty`) deliberately still reaches Vite in dev, while production static hosting answers it with the raw file. + +`verify-doc-site-fragments`, the post-build gate, fails the build when any route's twin or `llms.txt` is missing from the output, so deleting the `buildEnd` wiring cannot pass CI. + +## Alternatives considered + +**`vitepress-plugin-llms`.** Actively maintained, MIT, used by the Vite, Vue, and Vitest sites; it generates per-page Markdown, `llms.txt`, `llms-full.txt`, and dev-server responses. Two hard-coded behaviors break this site. It flattens `dir/index.md` to `dir.md` with no opt-out while leaving in-page links unrewritten, which 404s every relative link into a section landing page — this site publishes seven index routes per locale. And it rewrites image references to root-absolute hashed bundle paths without the site base, which 404s on this subpath GitHub Pages deployment; its adopters deploy at domain roots and never hit either. Its `llms.txt` also reads sidebars only from the top-level theme config, not this site's per-locale ones. Correcting all that means a fork or a post-processing layer coupled to plugin internals — more owned surface than the small emitter reusing the tested projector. + +**Emit through Vite's `publicDir`.** Vite supports one public directory and the site already points it at the tracked `website/public/`; generated twins would land in a tracked tree the layout gate forbids. + +**Absolute links inside the twins.** platform.claude.com links absolutely because its host is fixed. This site's base varies between local `/` and the Pages subpath, and the projector's relative links resolve in both trees as they are, so absolute rewriting would add a second link grammar without improving resolution. + +**Skip locale homes in the raw tree.** Published pages link to `docs/user/index.md`, so omitting the home routes breaks link closure. The body that survives chrome stripping (the H1) costs nothing, and the redirect frontmatter means nothing outside VitePress. + +## Consequences + +Agents fetch any page as plain Markdown by dropping the URL's trailing slash and appending `.md`, and discover the whole set at `/llms.txt`; the rendered site is unchanged. The build output carries one extra Markdown file per route, an alias per index route, and image copies beside them — kilobytes against the bundled assets. Twin content keeps GitHub-style heading text while the rendered site slugs punctuation-heavy headings differently; agents resolve headings themselves, so no gate covers raw-tree fragments. Deferred as unneeded for the twins' audience: `llms-full.txt`, and a per-page "view as Markdown" control, which would require a theme directory the stock-theme site deliberately lacks. diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md new file mode 100644 index 0000000000..1730c9e23e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -0,0 +1,37 @@ +# Agent Note:文档站的纯 Markdown 孪生页与 llms.txt + +Status: implemented + +[English](2026-08-20-doc-site-raw-markdown-twins.md) | 中文 + +## Problem + +文档站只提供渲染后的 HTML,agent 读文档要么抓取 VitePress 标记,要么退回仓库源文件,而后者的链接和图片引用跟随源码布局而非公开路由。Claude 平台文档确立了本特性采纳的约定:任意页面 URL 加 `.md` 后缀即得到该页的原始 Markdown,站根 `llms.txt` 作为面向 agent 的索引。[站点投影](2026-07-13-documentation-site-projection.zh.md)本就为公开站点重写每页链接,缺的只是把这份投影以纯 Markdown 形式对外提供。 + +## Decision + +`vitepress build` 结束时向构建输出发射每个已发布路由的纯 Markdown 孪生页。`emitRawMarkdownPages` 复用填充 `website/.generated/` 的同一趟 manifest 加投影器流程,但以原始页面内容写入 `/`:不带 `editSource`/`outline` 投影 frontmatter,不做 locale 首页截断——frontmatter 是 VitePress 的渲染配置,孪生页丢弃它并保留正文——仓库版式(语言切换行、徽章)的剥离与渲染站一致。页面引用的图片复制到孪生页旁边。 + +一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 + +index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。 + +`llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 + +开发服务器对导航与无头客户端提供同一表面。doc-projector 插件中的 middleware 逐请求从 canonical 源投影 `.md` 请求并按需生成 `llms.txt`,`docs:dev` 无需重建即与线上一致;页面内 `fetch()`(`Sec-Fetch-Dest: empty`)在 dev 下刻意仍交给 Vite,而生产静态托管对它返回原始文件。 + +构建后门禁 `verify-doc-site-fragments` 在任一路由的孪生页或 `llms.txt` 缺失时判定构建失败,删掉 `buildEnd` 接线无法通过 CI。 + +## Alternatives considered + +**`vitepress-plugin-llms`。** 维护活跃、MIT 协议,Vite、Vue、Vitest 官方站点在用;能生成每页 Markdown、`llms.txt`、`llms-full.txt` 和开发服务器响应。但两个硬编码行为在本站产生破坏。它把 `dir/index.md` 展平为 `dir.md` 且无法关闭,同时不改写页内链接,所有指向栏目落地页的相对链接全部 404——本站每个语言树发布七个 index 路由。它还把图片引用改写为不带站点 base 的根绝对哈希资产路径,在本站的子路径 GitHub Pages 部署下必然 404;其采用者都部署在域名根,两个问题都碰不到。其 `llms.txt` 只认顶层 theme 配置的侧边栏,认不出本站按 locale 存放的侧边栏。修正这些意味着 fork 或一层耦合插件内部行为的后处理——比复用已测投影器的小发射器拥有更多自有面积。 + +**经 Vite `publicDir` 发射。** Vite 只支持一个 public 目录,本站已将其指向被跟踪的 `website/public/`;生成的孪生页会落入布局门禁禁止的跟踪树。 + +**孪生页内用绝对链接。** platform.claude.com 用绝对链接是因为其主机固定。本站 base 在本地 `/` 与 Pages 子路径之间变化,而投影器的相对链接在两棵树中原样可解析,绝对化改写只会引入第二套链接语法而不改善解析。 + +**原始树跳过 locale 首页。** 已发布页面链接到 `docs/user/index.md`,省略首页路由会破坏链接封闭。剥离版式后剩下的正文(即 H1)成本为零,重定向 frontmatter 在 VitePress 之外没有意义。 + +## Consequences + +Agent 把页面 URL 去掉末尾斜杠再加 `.md` 即获取该页纯 Markdown,并在 `/llms.txt` 发现全集;渲染站不变。构建输出为每个路由多带一个 Markdown 文件、每个 index 路由多带一个别名,外加旁置图片副本——相对打包资产只是千字节级。孪生页保留 GitHub 风格的标题文本,而渲染站对含标点的标题使用不同 slug;agent 自行解析标题,因此没有门禁覆盖原始树的 fragment。因孪生页受众不需要而暂缓:`llms-full.txt`,以及每页的"查看 Markdown"控件——后者需要 stock-theme 站点刻意不设的 theme 目录。 diff --git a/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.i18n.yaml new file mode 100644 index 0000000000..3c14bc0cbf --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.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/implemented/process/2026-08-21-documentation-site-tag-release.md +2026-08-21-documentation-site-tag-release.md: 3f7a9d04a018bdcaefb1f09e5f2410b138c0c8d4 +2026-08-21-documentation-site-tag-release.zh.md: cd1f128bc6e9fb9370162eda95170c7d27ad7b63 diff --git a/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.md b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.md new file mode 100644 index 0000000000..3f7a9d04a0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.md @@ -0,0 +1,41 @@ +# Agent Note: Publish the documentation site from a release tag + +Status: implemented + +English | [中文](2026-08-21-documentation-site-tag-release.zh.md) + +## Problem + +Every other public surface of this project advances only at a release tag. The npm sequence publishes from a `dsh-v*` tag through a reviewed manual dispatch, the Python wheels from a `python-v*` tag behind a publisher-repository check and two protected environments, and the public source repository advances only to each release commit. The documentation website deployed on every master push touching `docs/`, `website/`, the projector, or the lockfile, with no reviewer and no version check. That site is reachable without authentication even though the repository is private, so a merge published documentation to the internet within minutes — including pages describing work that no released artifact contained, and reference material generated from a source tree ahead of everything readers could obtain. + +## Decision + +`docs-pages.yml` declares `workflow_dispatch` alone and publishes from a `dsh-v*` tag, which is the structure `release-publish.yml` already uses for npm: publication is an explicit act from a release tag and never appears as a pull-request check. + +The build job runs `release:verify --family dsh` with `RELEASE_PUBLISH=true` before it builds anything. That is the gate npm publication runs, so the site and the npm sequence share one definition of a released version rather than each carrying its own: the run must come from a `refs/tags/` ref, the tag must carry the family prefix, and the tag must name a version the working tree actually carries. Checkout takes complete history because the release scripts read tags. + +The `github-pages` environment carries a `dsh-v*` deployment tag policy and required reviewers, matching `npm-publish`. The two layers answer different failures: the script gate rejects a dispatch from the wrong ref, and the environment policy still rejects the deployment if a later workflow edit stops asking. + +`DOCS_REPOSITORY_REF` stays `master`, so projected source links continue to target the public repository's default branch rather than the dispatched tag. That repository advances only to each release commit, so its master never carries unreleased work and the published tag adds no exposure control. It also retains only its most recent tags, so following the dispatched tag would leave every projected source link on a deploy from an older tag unresolvable. + +Build coverage does not depend on this workflow. `check:ci:static` builds the production site on every pull request through `docs:build:mpa`, and `ci-master.yml` builds it again on master; [the projection Agent Note](2026-07-13-documentation-site-projection.md) rejected moving that build into a deployment workflow for exactly this reason, and tag-gated publication is what makes that separation load-bearing. + +`ci-workflow.spec.ts` pins the shape beside the npm and Python release assertions: `on` carries `workflow_dispatch` alone, the build job runs the tag verification with `RELEASE_PUBLISH`, checkout takes complete history, `DOCS_REPOSITORY_REF` reads `master`, and the deploy job keeps the `github-pages` environment. + +## Alternatives considered + +**Keep the master push and add required reviewers to the environment.** This puts a human in the loop for one workflow change and no repository-settings coordination. It fails on what the reviewer is asked: an approval prompt on every documentation merge is a prompt reviewers learn to clear without reading, and nothing connects the approved content to a version anyone can install. The reviewer would be answering "does this page look publishable" when the question is "is this released". + +**Follow the dispatched tag in `DOCS_REPOSITORY_REF`.** A page from a tagged snapshot linking to that snapshot's source is more precise than linking to a branch. It costs more than it buys here: the public repository keeps only its most recent tags, so a deploy from an older tag — the natural response to a bad release — breaks every projected source link at once, while its master cannot expose unreleased work in the first place. + +**Publish a separate public site and keep an internal site on master.** An internal site following master preserves same-day preview for contributors while the public site tracks releases. GitHub serves one Pages site per repository, so this needs a second repository, and the public organization does not currently run checked-in CI workflows. Deferred rather than rejected: this decision does not foreclose it, since a second destination consumes the same `website/.dist` this workflow already builds. + +**Make the Pages site private.** Switching Pages visibility to organization members is one repository setting and instant, and this organization's plan supports it. It answers a different question — it hides the site from readers who are meant to have it — while leaving documentation publication unlinked from release state. Tag-gated publication makes the site's content equal to what the public repository already carries, so restricting visibility buys nothing. + +## Consequences + +Documentation, npm packages, Python wheels, and the public source repository now advance together at each release tag, and no merge reaches a public reader on its own. Publishing costs one dispatch per release, and documentation fixes between releases stay unpublished until the next tag — which is the same latency the other three surfaces carry. A dispatch from a branch, or from a tag whose version the tree does not carry, fails at the verification step before the build runs. + +The workflow no longer proves that the Pages-flavored build works on each merge, because it no longer runs on merges; `check:ci:static` and `ci-master.yml` cover the production build, and the base-path configuration this workflow adds on top is exercised only at publication time. + +A site already deployed from a master push keeps serving that content until the first tagged dispatch replaces it. Dispatching an existing tag runs the workflow file as it exists at that tag, so a tag predating this change deploys through the previous workflow definition. diff --git a/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.zh.md b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.zh.md new file mode 100644 index 0000000000..cd1f128bc6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-21-documentation-site-tag-release.zh.md @@ -0,0 +1,41 @@ +# Agent Note:文档站从发布 tag 发布 + +Status: implemented + +[English](2026-08-21-documentation-site-tag-release.md) | 中文 + +## Problem + +本项目对外的每一个面都只在发布 tag 上前进。npm 序列从 `dsh-v*` tag 经人工审阅的手动 dispatch 发布,Python wheel 从 `python-v*` tag 发布,背后有发布仓库校验和两个受保护环境,公开源码仓库也只推进到每个发布 commit。文档网站却在每次触及 `docs/`、`website/`、投影器或锁文件的 master 推送上部署,既没有审阅人也没有版本校验。尽管仓库是私有的,该站点无需认证即可访问,因此一次合并会在几分钟内把文档发布到公网:其中包含描述尚无任何已发布产物承载的工作的页面,以及从领先于读者所能获取的一切的源码树生成的参考资料。 + +## Decision + +`docs-pages.yml` 只声明 `workflow_dispatch`,并从 `dsh-v*` tag 发布,这正是 `release-publish.yml` 为 npm 采用的结构:发布是从发布 tag 出发的显式动作,绝不作为拉取请求检查出现。 + +build 作业在构建任何东西之前先以 `RELEASE_PUBLISH=true` 运行 `release:verify --family dsh`。这就是 npm 发布所用的门禁,因此站点和 npm 序列共用同一个「已发布版本」的定义,而不是各自携带一份:运行必须来自 `refs/tags/` ref,tag 必须带有该家族前缀,且 tag 必须命名工作树确实携带的版本。checkout 取完整历史,因为发布脚本要读 tag。 + +`github-pages` 环境携带 `dsh-v*` 部署 tag 策略和必需审阅人,与 `npm-publish` 一致。两层应对的是不同的失效:脚本门禁拒绝从错误 ref 发起的 dispatch,而当日后某次工作流编辑不再校验时,环境策略仍会拒绝该次部署。 + +`DOCS_REPOSITORY_REF` 保持 `master`,因此投影出的源码链接继续指向公开仓库的默认分支,而不是被 dispatch 的 tag。该仓库只推进到每个发布 commit,所以它的 master 从不携带未发布的工作,被发布的 tag 在暴露控制上没有任何增益。它还只保留最近的若干 tag,因此跟随被 dispatch 的 tag 会让从较旧 tag 部署的站点上每一条投影源码链接都无法解析。 + +构建覆盖不依赖本工作流。`check:ci:static` 在每个拉取请求上通过 `docs:build:mpa` 构建生产站点,`ci-master.yml` 在 master 上再构建一次;[投影 Agent Note](2026-07-13-documentation-site-projection.zh.md) 正是出于这个理由否决了把该构建挪进部署工作流,而 tag 门禁化的发布让这条分离真正承重。 + +`ci-workflow.spec.ts` 在 npm 与 Python 发布断言旁钉住这个形状:`on` 只携带 `workflow_dispatch`,build 作业以 `RELEASE_PUBLISH` 运行 tag 校验,checkout 取完整历史,`DOCS_REPOSITORY_REF` 读作 `master`,deploy 作业保留 `github-pages` 环境。 + +## Alternatives considered + +**保留 master 推送,只给环境加必需审阅人。** 这只需改一处工作流,且不必协调仓库设置,就能把人放进回路。它败在审阅人被问的问题上:每次文档合并都弹出的审批提示,是审阅人会学会不读就放行的提示,而且没有任何东西把被批准的内容与任何人可安装的版本联系起来。审阅人回答的是「这页看着能发吗」,而问题是「这是已发布的吗」。 + +**让 `DOCS_REPOSITORY_REF` 跟随被 dispatch 的 tag。** 让来自某个 tag 快照的页面链接到该快照的源码,比链接到一个分支更精确。但在这里代价大于收益:公开仓库只保留最近的若干 tag,因此从较旧 tag 部署(正是一次糟糕发布的自然应对)会让全部投影源码链接一次性失效,而它的 master 本就不可能暴露未发布的工作。 + +**发布一个独立的公开站点,内部站点继续跟随 master。** 内部站点跟随 master 能为贡献者保留当天预览,同时公开站点跟随发布。GitHub 每个仓库只提供一个 Pages 站点,因此这需要第二个仓库,而公开组织目前不运行签入的 CI 工作流。这是押后而非否决:本决策没有堵死它,因为第二个目的地消费的正是本工作流已经构建出的 `website/.dist`。 + +**把 Pages 站点改为私有。** 把 Pages 可见性切到组织成员只需一处仓库设置且即时生效,本组织的套餐也支持。它回答的是另一个问题:它把站点从本应看到它的读者面前藏起来,同时并未把文档发布与发布状态挂钩。tag 门禁化的发布让站点内容等同于公开仓库已经承载的内容,因此限制可见性没有任何增益。 + +## Consequences + +文档、npm 包、Python wheel 和公开源码仓库现在在每个发布 tag 上一同前进,没有哪次合并能独自抵达公开读者。发布的代价是每次发布一次 dispatch,而两次发布之间的文档修复要等到下一个 tag 才会公开:这与另外三个面承担的延迟相同。从分支发起的 dispatch,或从一个工作树并不携带其版本的 tag 发起的 dispatch,会在构建运行之前于校验步骤失败。 + +本工作流不再于每次合并证明 Pages 风味的构建可用,因为它不再在合并上运行;`check:ci:static` 和 `ci-master.yml` 覆盖生产构建,而本工作流在其之上追加的 base 路径配置只在发布时被执行到。 + +已经从 master 推送部署出去的站点会继续提供那份内容,直到第一次 tag dispatch 替换它。对一个既有 tag 发起 dispatch 会运行该 tag 上的工作流文件,因此对早于本次改动的 tag 发起 dispatch 会走此前的工作流定义。 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml similarity index 61% rename from .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml rename to .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml index 0c4a921c34..8bf3d97c55 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md -2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977 -2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md +2026-08-22-native-windows-blocks-pull-request-aggregate.md: ddec9536cbb350d3792ae547150b175ef21f1b9e +2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md: 94fa8b3836c15b9c977c39c7539cbb1be5fc882b diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md new file mode 100644 index 0000000000..ddec9536cb --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.md @@ -0,0 +1,31 @@ +# Agent Note: Native Windows blocks the pull-request aggregate + +Status: implemented + +English | [中文](2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md) + +## Problem + +Wine reaches blocking win32 toolchain paths quickly, but it cannot prove behavior that depends on the NT kernel, NTFS, PowerShell, Windows process control, or native addons. An `all checks passed` result that can succeed while the complete native job is pending or failed does not enforce the repository's supported Windows behavior. + +The native job runs the complete supported-source coverage denominator and its owning Windows acceptance inventory. Its optimized 16-core hosted run completes within the five-minute target, making that higher-fidelity result short enough for the required pull-request path. + +## Decision + +The `all-checks-passed` job in [ci.yml](../../../../.github/workflows/ci.yml) lists both `windows` and `windows-native` in `needs`. Its existing `if: always()` verdict treats a failed, cancelled, or skipped native job like any other unsuccessful dependency, so `all checks passed` cannot succeed until the real-Windows job succeeds. + +Branch protection continues to require the single stable `all checks passed` context rather than adding the native job name as another protected context. The [dual Windows topology](2026-08-08-native-windows-pull-request-ci.md) owns each job's host, failover selector, and inventory; this note owns their blocking relationship. The aggregate bookkeeping job follows the Linux failover selector for its own runner while `needs` independently waits for the pool selected by `DSH_CI_FAILOVER_WINDOWS`. + +## Alternatives considered + +**Keep native Windows informational.** This preserves the shortest aggregate path, but permits a merge while the highest-fidelity supported Windows verdict is pending or red. + +**Require `windows node 24 / native complete` directly in branch protection.** This duplicates workflow topology in repository settings and makes a job-name change a control-plane migration. The aggregate already provides one stable required context and fails closed over unsuccessful dependencies. + +**Remove Wine from the aggregate.** Native Windows provides higher fidelity, but Wine still returns a faster win32 build and production-site signal, preserves the compatibility topology, and gives maintainers earlier failure evidence while the native inventory runs. + +## Consequences + +Every merge waits for native Windows runner capacity and for the complete native job to finish. A failure, cancellation, or skip in that job makes `all checks passed` fail; a passing Wine job alone is insufficient. + +The workflow remains one pull-request Action with one native Windows job, unchanged test coverage, and unchanged gate semantics inside that job. The required aggregate gains the native job's measured duration without adding a separately managed branch-protection context. diff --git a/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md new file mode 100644 index 0000000000..94fa8b3836 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-22-native-windows-blocks-pull-request-aggregate.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 原生 Windows 阻断拉取请求聚合流程 + +Status: implemented + +[English](2026-08-22-native-windows-blocks-pull-request-aggregate.md) | 中文 + +## 问题 + +Wine 能快速触达阻断性 win32 工具链路径,但无法证明依赖 NT 内核、NTFS、PowerShell、Windows 进程控制或原生插件的行为。如果 `all checks passed` 能在完整原生作业仍处于待处理或失败状态时成功,它就没有强制验证仓库所支持的 Windows 行为。 + +原生作业会运行完整的受支持源码覆盖率分母及其所属 Windows 验收清单。优化后的 16 核托管运行能在五分钟目标内完成,因此这项保真度更高的结果足够短,可以进入必需的拉取请求路径。 + +## 决策 + +[ci.yml](../../../../.github/workflows/ci.yml) 中的 `all-checks-passed` 作业会在 `needs` 中同时列出 `windows` 与 `windows-native`。其现有的 `if: always()` 判定会像处理其他未成功依赖项一样处理失败、取消或跳过的原生作业,因此真实 Windows 作业成功前,`all checks passed` 无法成功。 + +分支保护继续要求单一且稳定的 `all checks passed` 检查,而不把原生作业名称添加为另一个受保护检查。[Windows 双通道拓扑](2026-08-08-native-windows-pull-request-ci.zh.md)负责每个作业的宿主、故障转移选择器与清单;本文负责二者的阻断关系。聚合记账作业为自身运行器采用 Linux 故障转移选择器,而 `needs` 会独立等待 `DSH_CI_FAILOVER_WINDOWS` 所选池中的作业。 + +## 曾考虑的替代方案 + +**让原生 Windows 只提供信息。** 这会保留最短的聚合路径,但也允许在保真度最高的受支持 Windows 判定仍处于待处理或红灯状态时合并。 + +**在分支保护中直接要求 `windows node 24 / native complete`。** 这会在仓库设置中复制工作流拓扑,并使作业名称变更成为控制面迁移。现有聚合流程已经提供一个稳定的必需检查,并会对未成功的依赖项快速失败。 + +**从聚合流程移除 Wine。** 原生 Windows 的保真度更高,但 Wine 仍能更快返回 win32 构建与生产网站信号、保留兼容性拓扑,并在原生清单运行期间更早地为维护者提供失败证据。 + +## 后果 + +每次合并都会等待原生 Windows 运行器容量与完整原生作业结束。该作业失败、取消或跳过都会使 `all checks passed` 失败;仅 Wine 作业通过并不足够。 + +工作流仍然是单个拉取请求 Action,并保留一个原生 Windows 作业、不变的测试覆盖率以及该作业内不变的门禁语义。必需聚合流程会增加原生作业的实测时长,但无需新增单独管理的分支保护检查。 diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.i18n.yaml b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.i18n.yaml new file mode 100644 index 0000000000..4a697578a2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.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/implemented/process/2026-08-23-client-cross-package-value-dependencies.md +2026-08-23-client-cross-package-value-dependencies.md: b4db6c75e245db37848d0389631441a585c82188 +2026-08-23-client-cross-package-value-dependencies.zh.md: 9894f743112157017c7d675eeac8adcb3aacff82 diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md new file mode 100644 index 0000000000..b4db6c75e2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.md @@ -0,0 +1,49 @@ +# Agent Note: Classifying Client cross-package value dependencies + +Status: implemented + +English | [中文](2026-08-23-client-cross-package-value-dependencies.zh.md) + +## Problem + +The Client package splits in [PR #2728](https://github.com/deepseek-ai/deepseek-harness/pull/2728) and [PR #2911](https://github.com/deepseek-ai/deepseek-harness/pull/2911) left 15 `dsh.client.external` requests in feature-plugin manifests. Those requests turned ordinary value imports into synchronous module-table ordering constraints, even when the consumer needed only a type, a small pure conversion, or access to an already-injected Cordis service. + +Removing every import mechanically would create different coupling: a general utility package could become a miscellaneous business owner, a service could carry pure presentation transforms, or duplicated target behavior could be centralized only to satisfy clone detection. Client maintenance needs one repeatable classification before choosing where a cross-package reference belongs. + +## Decision + +Every Client cross-package reference is classified by what crosses the package boundary. A feature plugin does not import a runtime value from another feature plugin and does not declare `dsh.client.external`. The [Client shell layering decision](../architecture/2026-08-15-client-shells-and-dynamic-packages.md) continues to own bundle construction and module-table loading; this decision narrows how feature code uses those mechanisms. + +| Case | Treatment | Reason | +| --- | --- | --- | +| Unused value or forwarding export | Delete it | A dependency without a caller has no owner to preserve. | +| Shared declaration | Import it with `import type` from the declaring package | Erased imports retain one type authority without a runtime edge. | +| Stateful, lifecycle-bound, or callable feature behavior | Expose it through an injected Cordis service | The providing plugin owns implementation and lifecycle; consumers depend on the service name and interface. | +| Presentation contribution | Register it through the declaring slot | The owner controls placement while contributors remain independently loadable. | +| Generic stateless helper or primitive | Put it in a narrow static utility package or `ui-primitives` | Multiple packages may synchronously share behavior only when it has no feature state, lifecycle, or domain authority. | +| Small target-specific projection | Keep one local implementation in each target | Chat and Trajectory may intentionally interpret the same durable event independently; sharing code alone does not justify a feature dependency. | +| Generated Remote artifact | Import it only in the API transport assembly that owns generated registration | Generated providers are transport wiring, not a feature package's callable helper API. | + +Intentional target-local copies wrap only the duplicated implementation in `jscpd:ignore-start` / `jscpd:ignore-end`, with a comment naming the independent owners. The exclusion must not cover surrounding business logic. Generic behavior moves to a utility only when its semantics are stable outside every current caller; this cleanup places Workspace path formatting in `dsh-util-workspace-path`, byte encoding in `dsh-util-crypto`, and the shared reference glyph in `ui-primitives`. + +`verify-client-packages` rejects every `dsh.client.external` declaration under `packages/client/*`. Outside that feature tree, each declaration must correspond to a production runtime import or re-export. The two retained requests are Session Controller → API Gateway and Workspace Controller → API Gateway; both are transport infrastructure. The Client bundle preset separately rejects workspace runtime imports that are neither module-table requests nor explicitly allowlisted static inputs. + +Host-facing transport adapters remain outside the feature-plugin prohibition. Connection may use API Proxy's carrier implementation, and `api/remotes` may load a generated Host Remote provider. These imports assemble transport rather than sharing feature behavior. + +## Alternatives considered + +**Put every reused value on `uiConversation`.** Rejected because pure event-to-view conversions would become service calls or feature exports, forcing Chat, Trajectory, Approval, Question, Subagent, and Workspace to load an unrelated feature owner. + +**Keep feature `dsh.client.external` declarations.** Rejected because successful loading would preserve the synchronous value dependency and merely make its ordering explicit. + +**Move every repeated function into one utility package.** Rejected because target-specific interpretation would acquire a false shared owner. Only state-free behavior with meaning independent of its callers belongs in a static utility. + +**Ignore all duplicate Client code.** Rejected because duplication remains useful evidence by default. An ignore is narrow and documents the deliberate independence of named targets. + +## Consequences + +The 15 feature-plugin external requests are absent, while shared declaration imports remain explicit and type-only. Feature loading order follows Cordis services and slots instead of synchronous feature-module imports. + +Some short projection functions exist twice. Their owners can evolve independently, and clone detection still covers all code outside the annotated copies. Static utility packages gain a small public API and must remain state-free and browser-safe. + +The rule is role-specific rather than a blanket ban on cross-package values. Infrastructure adapters and generated registration artifacts remain direct imports where loading or protocol assembly requires them, and `verify-client-packages` keeps those exceptions visible and live. diff --git a/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md new file mode 100644 index 0000000000..9894f74311 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-23-client-cross-package-value-dependencies.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Client 跨包值依赖分类 + +Status: implemented + +[English](2026-08-23-client-cross-package-value-dependencies.md) | 中文 + +## 问题 + +[PR #2728](https://github.com/deepseek-ai/deepseek-harness/pull/2728) 与 [PR #2911](https://github.com/deepseek-ai/deepseek-harness/pull/2911) 拆分 Client 包后,功能插件 manifest 中还留有 15 条 `dsh.client.external` 请求。即使消费方只需要一个类型、一段小型纯转换或访问已经注入的 Cordis service,这些请求也会把普通值 import 变成同步模块表顺序约束。 + +机械删除所有 import 会产生别的耦合:通用工具包可能变成杂项业务 owner,service 可能承载纯展示转换,或者只为通过重复检测而把 target 行为集中到一处。维护 Client 时,需要先用同一套流程分类,再决定跨包引用应当放在哪里。 + +## 决策 + +每条 Client 跨包引用都按实际跨越包边界的内容分类。功能插件不从另一个功能插件导入运行时值,也不声明 `dsh.client.external`。[Client shell 分层决策](../architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md)继续负责 bundle 构建与模块表加载;本决策进一步限定功能代码如何使用这些机制。 + +| 情形 | 处理方式 | 原因 | +| --- | --- | --- | +| 未使用的值或转发 export | 删除 | 没有调用方的依赖不需要保留 owner。 | +| 共享声明 | 从声明方包使用 `import type` 导入 | 被擦除的 import 保留单一类型权威,但不产生运行时边。 | +| 有状态、受生命周期约束或可调用的功能行为 | 通过注入的 Cordis service 暴露 | 提供插件拥有实现与生命周期;消费方只依赖 service 名称和接口。 | +| 展示贡献 | 通过声明方 slot 注册 | owner 控制放置位置,各贡献方仍可独立加载。 | +| 通用无状态辅助函数或基础组件 | 放入窄职责静态工具包或 `ui-primitives` | 只有不持有功能状态、生命周期或领域权威的行为才允许被多个包同步共享。 | +| 小型 target 专属投影 | 每个 target 保留一份本地实现 | Chat 与 Trajectory 可以独立解释同一持久事件;仅仅复用代码不足以证明应建立功能依赖。 | +| 生成的 Remote 产物 | 只在拥有生成注册的 API 传输组装层导入 | 生成的 provider 是传输接线,不是功能包的可调用辅助 API。 | + +有意保留的 target 本地副本只用 `jscpd:ignore-start`/`jscpd:ignore-end` 包住重复实现,并在注释中点名相互独立的 owner;排除范围不得覆盖周围业务逻辑。只有语义独立于所有当前调用方时,通用行为才进入工具包;本次清理把 Workspace 路径格式化放入 `dsh-util-workspace-path`,把字节编码放入 `dsh-util-crypto`,把共享引用图标放入 `ui-primitives`。 + +`verify-client-packages` 拒绝 `packages/client/*` 下的所有 `dsh.client.external` 声明。在该功能树之外,每条声明都必须对应生产代码中的运行时 import 或 re-export。保留的两条请求是 Session Controller → API Gateway 与 Workspace Controller → API Gateway,二者都属于传输基础设施。Client bundle preset 还会拒绝既非模块表请求、也未被明确加入静态输入 allowlist 的 workspace 运行时 import。 + +面向 Host 的传输适配器不属于功能插件禁令。Connection 可以使用 API Proxy 的 carrier 实现,`api/remotes` 可以加载生成的 Host Remote provider;这些 import 用于组装传输,而不是共享功能行为。 + +## 考虑过的替代方案 + +**把所有复用值都放到 `uiConversation`。** 否决,因为纯 event→view 转换会变成 service 调用或功能 export,迫使 Chat、Trajectory、Approval、Question、Subagent 与 Workspace 加载一个无关的功能 owner。 + +**保留功能插件的 `dsh.client.external` 声明。** 否决,因为加载成功只会把同步值依赖的顺序显式化,不会消除该依赖。 + +**把每个重复函数都移入同一个工具包。** 否决,因为 target 专属解释会因此获得一个虚假的共享 owner。只有语义独立于调用方的无状态行为才属于静态工具。 + +**忽略全部 Client 重复代码。** 否决,因为重复默认仍是有用信号。每项 ignore 必须范围狭窄,并说明哪些具名 target 需要有意保持独立。 + +## 后果 + +15 条功能插件 external 请求已移除,共享声明 import 保持显式且仅类型化。功能加载顺序由 Cordis service 与 slot 决定,不再由同步功能模块 import 决定。 + +少量投影函数存在两份实现。各 owner 可以独立演进,重复检测仍覆盖注解副本以外的全部代码。静态工具包增加少量公共 API,并且必须保持无状态且可在浏览器运行。 + +这项规则按包角色区分,并非全面禁止跨包值。加载或协议组装需要的基础设施适配器与生成注册产物仍保留直接 import,`verify-client-packages` 则确保这些例外保持可见且确实仍被使用。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 275cfc5331..e98cb973bd 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 8761c360e492d6d315e738ed93441929584d1e20 -2026-07-20-remove-stdio-and-echo-agents.zh.md: e34672ce9739ab22e76e44c305c22efb30b6a0c4 +2026-07-20-remove-stdio-and-echo-agents.md: 1d71b74fda229f7eaed502b3badba2ab39e5ea54 +2026-07-20-remove-stdio-and-echo-agents.zh.md: 8445a82d7e366bf57ea4d3ad4595a596b67f1fa8 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 8761c360e4..1d71b74fda 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -20,7 +20,7 @@ The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. -- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-sdk-jsonrpc-server` own their framed protocol integrations. +- [`dsh --profile acp`](../../../../apps/cli/README.md) and `@deepseek-ai/dsh-sdk-jsonrpc-server` own their framed protocol integrations. The SDK project model that carried the `stdio` run-interface option is deleted by the [SDK project toolchain removal](2026-08-11-remove-sdk-project-toolchain.md). Repository-facing demo documentation requires a DeepSeek API key and leads with a current runnable product. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index e34672ce97..8445a82d7e 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -20,7 +20,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 - [`dsh --profile headless`](../../../../apps/cli/README.zh.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 -- [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.zh.md) 和 `@deepseek-ai/dsh-sdk-jsonrpc-server` 负责各自的分帧协议集成。 +- [`dsh --profile acp`](../../../../apps/cli/README.zh.md) 和 `@deepseek-ai/dsh-sdk-jsonrpc-server` 负责各自的分帧协议集成。 承载 `stdio` 运行接口选项的 SDK 项目模型已由 [SDK 项目工具链移除决策](2026-08-11-remove-sdk-project-toolchain.zh.md)删除。仓库中的演示文档要求 DeepSeek API key,并优先引导到当前可运行的产品。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml index 6131c6bb6a..dbf98656ed 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.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/simplification/2026-07-20-unwrap-injected-content-envelopes.md -2026-07-20-unwrap-injected-content-envelopes.md: c1a7376c0c9ba737daf4e35af5808afb74aa3026 -2026-07-20-unwrap-injected-content-envelopes.zh.md: bbd2b2f2000f5086710d2040813b7beec7538fda +2026-07-20-unwrap-injected-content-envelopes.md: 7d14ada7cc1b88071ec4f07870afcfff6211a883 +2026-07-20-unwrap-injected-content-envelopes.zh.md: 813adfb818cac35da6e8c3b279b2e795fa99ad1a diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md index c1a7376c0c..7d14ada7cc 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md @@ -19,7 +19,7 @@ Injected session content projects verbatim; the caller owns any framing. `derive The `ContextEnvelope` type and every `envelope` field are removed — `context/message` in `SessionEventMap`, `InjectOptions`, `HookContext`, and the `inject()`/`additionalContexts` plumbing in `dsh-agent-loop`. `agent-instructions` no longer requests `'raw'`; its self-framed content renders as before. The `renderTagged`/`renderContextEnvelope` helpers are deleted. `context/message.meta` still carries durable, model-hidden JSON state. -The `source` attribution the envelopes carried is not lost — it remains on the durable events; it simply no longer renders into the transcript. +The `source` attribution remains on the durable events and is omitted from the transcript. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md index bbd2b2f200..813adfb818 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md @@ -19,7 +19,7 @@ Status: implemented `ContextEnvelope` 类型和所有 `envelope` 字段都被移除——包括 `SessionEventMap` 中的 `context/message`、`InjectOptions`、`HookContext`,以及 `dsh-agent-loop` 中 `inject()`/`additionalContexts` 的相关管线。`agent-instructions` 不再请求 `'raw'`;它自带框架的内容渲染方式不变。`renderTagged`/`renderContextEnvelope` 辅助函数被删除。`context/message.meta` 仍携带持久的、对模型隐藏的 JSON 状态。 -封套曾携带的 `source` 来源信息并未丢失——它仍保留在持久事件上;只是不再渲染进 transcript。 +`source` 来源信息保留在持久事件上,不渲染进 transcript。 ## 权衡的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index dfb001aad5..363165af8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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/simplification/2026-07-22-plan-specific-collaboration-state.md -2026-07-22-plan-specific-collaboration-state.md: a85e7ced9f2792dfd447cb22e9fea08ce67256df -2026-07-22-plan-specific-collaboration-state.zh.md: 2754b01a41b573f809f556afa1d7dd213b07e597 +2026-07-22-plan-specific-collaboration-state.md: c2b882bd6f686662a2369d1b76d6e1611caee9b9 +2026-07-22-plan-specific-collaboration-state.zh.md: 1fdb4b260853be7480f1575d6ded86d036b7d746 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index a85e7ced9f..c2b882bd6f 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -43,7 +43,7 @@ The tool renders the submitted plan as a generic card titled by its first headin ## Alternatives considered -**Keep a private generic registry and expose only plan today.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases. +**Keep a private generic registry and expose only plan.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases. **Fold sandbox or approval policy into plan state.** Rejected because collaboration guidance, execution confinement, and permission decisions have different owners, lifecycle semantics, and consumers. A mode-owned sandbox cap also makes a user's explicit sandbox selection appear to succeed while silently doing nothing. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 2754b01a41..1fdb4b2608 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -43,7 +43,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` ## 考虑过的替代方案 -**保留私有的通用注册表,目前只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。 +**保留私有的通用注册表,只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。 **将沙箱或审批策略折叠进 plan 状态。** 不予采纳,因为协作引导、执行约束和权限决策有不同的归属方、生命周期语义和消费方。由 mode 拥有的沙箱上限还会让用户显式选择沙箱看似成功,实际却被静默忽略。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 9bd8bace40..635851d4b8 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.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/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: deeba55ebb48468af80a6c74a704b18e07f33477 -2026-07-23-acp-automation-only-protocol.zh.md: 0b4d6b2886d34494d321b96abdc1cd30cb112312 +2026-07-23-acp-automation-only-protocol.md: 4bd02298d2a27809099efa90312762aaa1341075 +2026-07-23-acp-automation-only-protocol.zh.md: aa23c816802eae824e251f76b1d5cc6646e08dec diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index deeba55ebb..4bd02298d2 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-23-acp-automation-only-protocol.zh.md) +> The automation-only boundary remains current. [Standard ACP v1 automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) supersedes only this note's prompt-only method, configuration, MCP, update, and lifecycle inventory; it does not restore ACP as a UI. + ## Problem The ACP bridge had become a second interactive product UI. It translated durable events into editor cards, terminal metadata, diffs, plans, titles, reasoning, commands, modes, model and permission pickers, session navigation, and human elicitation. Those responsibilities duplicated the TUI and the Web client while coupling an automation transport to UI services, persistence queries, presentation policy, and editor-specific conventions. @@ -14,15 +16,15 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble ## Decision -`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh sessions with one in-flight prompt each, committed assistant text/image updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge rejects additional directories, MCP servers, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. +`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol contains standard automation controls rather than presentation: persistent session creation/list/resume/close, one in-flight prompt per session, model configuration, stdio/HTTP MCP mounting, committed semantic updates, cancellation, concurrent sessions, and one-shot permission requests. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge still rejects additional directories, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. -Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults. +Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each prompt snapshots its exact route before asynchronous admission, strictly decodes every image block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. That same snapshot drives the admitted turn even if the next-turn configuration changes concurrently. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults. -The bridge emits only committed `assistant/message` text and images. A per-session promise chain preserves block and message order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. +The bridge emits only committed semantic facts. A per-session promise chain preserves reasoning, assistant block, tool lifecycle, configuration, and usage-update order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Raw chunks, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and presentation projections remain off the ACP wire. Standard model and reasoning options, list/resume/close, and stdio/HTTP MCP are automation controls; session load/delete/fork, commands, modes, plan review, terminals, client filesystem operations, and human elicitation remain unsupported. One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. -The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-questions services for ACP. +The app composition contains the agent spine, persistence, checkpoint policy, derived session query, and ACP transport. The ACP bridge reads persistence directly for standard resumable summaries; it does not expose command, session-reference, plan-mode, permission-picker, or user-question presentation surfaces. The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output. @@ -30,7 +32,7 @@ Disconnect and plugin disposal share one memoized quiescence boundary. Both succ ## Snapshot boundary -The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. +The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite. Standard resume restores a persisted Agent without replaying transcript UI, while semantic-checkpoint recovery coverage may still use the headless SDK example when that protocol is the subject. Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup or cancellation of unrelated Agent work, exclusion of unrelated pre-inbox failures, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. @@ -56,6 +58,6 @@ Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthf ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. -Automation clients receive complete committed text/images rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. +Automation clients receive committed message, reasoning, generic tool, configuration, and usage facts rather than token deltas or structured tool UI. Standard list/resume/close and session configuration cover automation lifecycle without adding navigation, transcript replay, titles, or other human presentation. Backend snapshot coverage therefore remains transport-coupled to ACP even though that transport is incidental to the behavior under test. diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 0b4d6b2886..aa23c81680 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-23-acp-automation-only-protocol.md) | 中文 +> 仅面向自动化的边界仍然有效。[标准 ACP v1 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)仅取代本说明中仅支持提示词的方法、配置、MCP、更新和生命周期清单;它不会把 ACP 恢复为 UI。 + ## 问题 ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理(reasoning)、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。 @@ -14,15 +16,15 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 ## 决策 -`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.zh.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本/图片更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层会拒绝附加目录、MCP 服务器、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 +`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.zh.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议包含标准自动化控制,而非展示:持久会话创建/列出/恢复/关闭、每会话一个在途提示词、模型配置、stdio/HTTP MCP 挂载、已提交语义更新、取消、并发会话和一次性权限请求。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层仍拒绝附加目录、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 -图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。 +图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个提示词都会在异步准入前快照确切路由、严格解码全部图片块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。即使下一轮配置并发变化,同一快照仍驱动已准入轮次。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。 -桥接层只发出已提交的 `assistant/message` 文本与图片。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时保持块与消息顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 +桥接层只发出已提交的语义事实。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时,保持 reasoning、assistant 块、工具生命周期、配置和用量更新顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。原始分片、待办事项、计划、标题、重试标记、终端元数据、diff、位置和展示投影不会进入 ACP wire。标准模型和 reasoning 选项、列出/恢复/关闭以及 stdio/HTTP MCP 属于自动化控制;会话加载/删除/fork、命令、模式、plan 评审、终端、客户端文件系统操作和面向人类的询问仍不受支持。 保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中登记的同一 agent 对象;不属于桥接层当前 agent 的请求或未关联具体调用的请求会继续委派;RPC 失败则映射为故障时默认拒绝的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.zh.md) 会以程序化方式使用该通道。 -应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。 +应用组装包含 agent 主干、持久化、检查点策略、派生会话查询和 ACP 传输层。ACP 桥接层直接读取持久化以生成标准可恢复摘要;它不公开命令、会话引用、plan mode、权限选择器或用户问题展示接口。 传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop(智能体循环)。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 @@ -32,7 +34,7 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 ## 快照边界 -ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 +ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除 UI 方法驱动的场景。标准恢复会还原持久 Agent,但不会重放 transcript UI;当 headless SDK 协议本身是测试对象时,语义检查点恢复覆盖仍可使用其示例。 协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup 或取消无关 Agent 工作、排除进入 inbox 前的无关失败、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 @@ -58,6 +60,6 @@ ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后 ACP 具有适合 agent 与自动化的精简约定,而 TUI 和 Web 拥有面向人类的交互与展示。该包注入的服务、依赖、协议分支和生命周期状态更少,也不再将自身定位为通用编辑器入口。 -自动化客户端收到完整的已提交文本/图片,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。 +自动化客户端收到已提交消息、reasoning、通用工具、配置和用量事实,而不是 token 增量或结构化工具 UI。标准列出/恢复/关闭和会话配置覆盖自动化生命周期,同时不增加导航、transcript 重放、标题或其他人工展示。 因此,后端快照测试仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml deleted file mode 100644 index 5ba8461a95..0000000000 --- a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md -2026-08-11-quickstart-documentation-home.md: 3fd98843fc0e3e09fc1f4a5623729511aba98def -2026-08-11-quickstart-documentation-home.zh.md: 63871b01c4b552834907ecd41ea089c71ed246d8 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index d8d6915f7b..f09942054d 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: bee59901eed7bb068dc93699aa2bb5e55216b8ff -2026-06-19-acp-snapshot-tests.zh.md: 032c681d3db464d6a5254f67a5eee1551256df66 +2026-06-19-acp-snapshot-tests.md: cd9a4903b1eaaf624166d23c25789d66b68a6e02 +2026-06-19-acp-snapshot-tests.zh.md: ce6eb812a6a9343c9c58acd205cb6e1f2e156258 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index bee59901ee..cd9a4903b1 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -12,9 +12,9 @@ The blocker for a full-transcript test is the model: the agent's output is drive ## Decision -A snapshot test boots the real ACP example, drives its stdio protocol from a deterministic script, and compares normalized output with committed expected outputs. A session log recorded once from the real API supplies all later model streams. The fixture is the product's ordinary persisted JSONL. +A snapshot test boots the real ACP example, drives its stdio protocol from a deterministic script, and compares normalized output with committed expected outputs. A session log recorded once from the real API supplies all later model streams. The fixture is a [projection of the product's persisted JSONL](2026-08-18-session-snapshot-envelope-projection.md): its header and payloads remain, while body sequence/time envelopes are omitted. -### The fixture is the persisted session JSONL +### The fixture projects the persisted session JSONL Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral expected output. @@ -42,7 +42,7 @@ Replay is positional and therefore permits only one in-flight model stream per s ### Recording harvests the log; keyless replay needs a providerless config -Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend configured with `persistenceCompression: 'none'`, then copies the produced `.jsonl` into the scenario dir. The explicit raw mode keeps committed replay fixtures line-readable while ordinary deployments use the backend's compressed default; eligible chunk runs still use the default packed storage rows. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. +Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend configured with `persistenceCompression: 'none'`, then projects the produced `.jsonl` into the scenario dir. The explicit raw mode keeps harvested logs line-readable while ordinary deployments use the backend's compressed default; eligible chunk runs still use the default packed storage rows. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with `llm-replay` while retaining the live composition. Recording uses the ordinary config and a harness-supplied persistence root. Replay mode skips `.env` loading, so a stray API key cannot trigger a live call. See the [single-source config Agent Note](../../archived/testing/2026-07-04-single-source-acp-replay-config.md). @@ -55,7 +55,7 @@ A snapshot run asserts **two** normalized outputs, because the harness's externa The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. -Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility; fixture projection omits body sequence/time envelopes without changing payload references. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes. ### Isolation: normalization now, sandbox later @@ -67,7 +67,7 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ### Two subcommands, replay in the default gate -`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout expected output. The same keyless gate discovers repository JSONL by its `session` header and rejects any fixture that differs from the shared codec's canonical packed representation. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.expected.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters. +`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the projected session snapshot and stdout expected output. The same keyless gate discovers repository JSONL by its `session` header and rejects any fixture that differs from the shared codec's projected canonical packed representation. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.expected.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 032c681d3d..ce6eb812a6 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的会话日志为后续所有模型流提供数据。fixture 就是产品普通的持久化 JSONL。 +快照测试会启动真实 ACP 示例,通过确定性脚本驱动其 stdio 协议,并将规范化输出与已提交的预期输出比较。从真实 API 一次记录的会话日志为后续所有模型流提供数据。fixture 是[产品持久化 JSONL 的投影](2026-08-18-session-snapshot-envelope-projection.zh.md):保留 header 与 payload,省略正文序号/时间 envelope。 -### fixture 即持久化的会话 JSONL +### fixture 投影持久化会话 JSONL 每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当回放来源和行为预期输出。 @@ -42,7 +42,7 @@ Status: implemented ### 录制采集日志;无密钥回放需要无提供方的配置 -记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交回放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值;符合条件的分片连续段仍使用默认的打包存储行。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责回放。 +记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 投影到场景目录。显式 raw 模式让采集日志保持逐行可读,而普通部署使用后端的压缩默认值;符合条件的分片连续段仍使用默认的打包存储行。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责回放。 回放使用 `cordis.snapshot.yml` overlay,以 `llm-replay` 替换真实适配器,同时保留实际组合。记录使用普通配置和由 harness 提供的持久化根目录。回放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发真实调用。参见[单一来源配置 Agent Note](../../archived/testing/2026-07-04-single-source-acp-replay-config.md)。 @@ -55,7 +55,7 @@ Status: implemented 两个表面互补:stdout 覆盖精简的自动化协议格式,JSONL 覆盖协议格式有意省略的循环、工具和边界结构。 -规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是符合协议格式的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 +规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值;fixture 投影会省略正文序号/时间 envelope,而不修改 payload 引用。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是符合协议格式的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。 ### 隔离:当前靠归一化,后续可加沙箱 @@ -67,7 +67,7 @@ Status: implemented ### 两个子命令,回放在默认门禁中 -`pnpm run test:snapshot` 无需密钥即可回放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写采集的会话日志与 stdout 预期输出。同一无密钥门禁会通过 `session` 头记录发现仓库中的 JSONL,并拒绝与共享编解码器的规范打包表示不同的任何 fixture。缺少 fixture 时会明确报错。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅含头记录的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生回放。fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 +`pnpm run test:snapshot` 无需密钥即可回放已提交 fixture;`test:snapshot:record` 使用真实 API,并重写投影后的会话快照与 stdout 预期输出。同一无密钥门禁会通过 `session` 头记录发现仓库中的 JSONL,并拒绝与共享编解码器的投影后规范打包表示不同的任何 fixture。缺少 fixture 时会明确报错。每个场景都包含 `input.json`、`stdout.expected.jsonl` 和 `session.jsonl`;不调用模型的情况使用仅含头记录的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`,因为它一旦存在就会取代派生回放。fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 881c830a38..676b7c29a9 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.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/testing/2026-06-19-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: feb5d83f087f66dfec7e1540f6b32e33656409d4 -2026-06-19-real-api-e2e-ci.zh.md: e808c057cbe49fe97a426de3437bd65161ae7a77 +2026-06-19-real-api-e2e-ci.md: 4f51032d90b6773d01db7bbfdced6328bd882ad0 +2026-06-19-real-api-e2e-ci.zh.md: 390df23aebec3a6a54bc48eb52022fe02e872164 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index feb5d83f08..4f51032d90 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -62,7 +62,7 @@ The DeepSeek native `web_search` probe is registered but skipped. The live Anthr The repository's first CI secret requires a recorded threat model because access differs between same-repository, fork, and Dependabot pull requests and changes when the repository becomes public. -### Who can reach the secret today (private repo) +### Who can reach the secret in a private repository - **No write access (fork PRs): cannot.** Two independent facts block it. First, the workflow uses `pull_request`, **not** `pull_request_target` — GitHub does not pass repo secrets to fork-PR runs of `pull_request`, so `secrets.DEEPSEEK_API_KEY_EXTERNAL` resolves to empty on a fork runner. Second, the `if:` gate skips fork PRs entirely. The withholding is the real boundary; the gate is defense-in-depth and UX. - **Write (push) access: can.** A same-repo branch PR receives secrets, so a write-access author could modify test code (or an install lifecycle script, or the workflow YAML on their branch) to exfiltrate the key. This is **inherent to GitHub Actions, not introduced here**: anyone with push access to any repo can already exfiltrate any of its Actions secrets by authoring a workflow. Write access ⇒ secret access, always. The mitigation lives in who is granted write and in branch protection, not in this file. @@ -79,7 +79,7 @@ The secret stays protected from the public **through this workflow**: `pull_requ What gets worse is the *surrounding* model, and these are the things to address before flipping visibility: -- **Logs become world-readable.** A careless secret echo that today leaks to org members would leak to the entire internet and be scraped within minutes. Secret-handling discipline (no value/length echoes — already done) matters far more. +- **Logs become world-readable.** A careless secret echo that leaks to organization members would leak to the entire internet and be scraped within minutes. Secret-handling discipline (no value/length echoes — already done) matters far more. - **The `pull_request_target` footgun becomes catastrophic.** If anyone ever "fixes" PR runs by switching the trigger to `pull_request_target`, the workflow would run untrusted fork code in the base-repo context **with** secrets — a full key-leak vector. This is benign-ish on a private repo and disastrous on a public one. A `SECURITY —` comment on the trigger in e2e.yml forbids the change and points here. - **Rotate on flip.** The key lived in a private repo's CI; treat going-public as "assume exposed" and rotate `DEEPSEEK_API_KEY_EXTERNAL` at that moment. - **Settle the secret behind controls.** Confirm Settings → Actions → *"Send secrets to workflows from fork pull requests"* stays **off** (the one setting that would actually break the fork boundary), and consider moving the key into a GitHub **Environment** with required reviewers so even merged code uses it only under controlled conditions and rotation has a single home. diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index e808c057cb..390df23aeb 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -62,7 +62,7 @@ DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼 仓库的首个 CI secret 需要一份记录在案的威胁模型,因为同仓库 PR、fork PR 和 Dependabot PR 的访问权限各不相同,且仓库公开后会发生变化。 -### 当前谁能触及 secret(私有仓库) +### 私有仓库中谁能触及 secret - **无写权限(fork PR):不能。** 两个独立事实阻止了它。第一,工作流使用 `pull_request` 而**非** `pull_request_target`——GitHub 不会将 repo secret 传递给 fork PR 的 `pull_request` 运行,因此 `secrets.DEEPSEEK_API_KEY_EXTERNAL` 在 fork runner 上解析为空。第二,`if:` 门禁完全跳过 fork PR。secret 扣留是真正的边界;门禁是纵深防御和用户体验。 - **有写(push)权限:能。** 同仓库分支 PR 会收到 secret,因此有写权限的作者可以修改测试代码(或安装生命周期脚本,或其分支上的工作流 YAML)来窃取密钥。这**是 GitHub Actions 的固有特性,并非本文引入的**:任何对任何仓库有 push 权限的人都可以通过编写工作流来窃取该仓库的任何 Actions secret。写权限⇒secret 访问权,始终如此。缓解措施在于谁被授予写权限以及分支保护,而非本文件。 @@ -79,7 +79,7 @@ DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼 变差的是*周边*模型,以下是翻转可见性之前需要处理的事项: -- **日志变为全球可读。** 今天泄露给组织成员的粗心 secret 回显,公开后会泄露给整个互联网并在数分钟内被爬取。secret 处理纪律(不回显值/长度——已做到)的重要性大幅提升。 +- **日志变为全球可读。** 泄露给组织成员的粗心 secret 回显,公开后会泄露给整个互联网并在数分钟内被爬取。secret 处理纪律(不回显值/长度——已做到)的重要性大幅提升。 - **`pull_request_target` 陷阱变为灾难性的。** 如果有人为了「修复」PR 运行而将触发器切换为 `pull_request_target`,工作流将在 base-repo 上下文中运行不可信的 fork 代码并**携带** secret——完整的密钥泄露向量。在私有仓库中这勉强无害,在公开仓库中则是灾难。e2e.yml 中触发器上的 `SECURITY —` 注释禁止此更改并指向本文。 - **翻转时轮换密钥。** 密钥曾存在于私有仓库的 CI 中;将公开视为「假定已暴露」,在那一刻轮换 `DEEPSEEK_API_KEY_EXTERNAL`。 - **将 secret 置于控制之下。** 确认 Settings → Actions → *"Send secrets to workflows from fork pull requests"* 保持**关闭**(这是唯一真正会打破 fork 边界的设置),并考虑将密钥移入带有 required reviewers 的 GitHub **Environment**,使即使已合并的代码也只在受控条件下使用它,且轮换有单一归属。 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml index 20b2d178bd..d923a2b4fe 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.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/testing/2026-06-22-fork-child-replay-seed-boundary.md -2026-06-22-fork-child-replay-seed-boundary.md: 2768f32adff2badf2d4b18d14ce7eebcb87fda09 -2026-06-22-fork-child-replay-seed-boundary.zh.md: d7a4468e5304a17b9b52bbc9669f6637e5207b15 +2026-06-22-fork-child-replay-seed-boundary.md: 0964f31de7038484f19cab2431bcd8e9f6113a37 +2026-06-22-fork-child-replay-seed-boundary.zh.md: 5caa5a8a66bc5630705319b180863b353c0e8249 diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index 2768f32adf..0964f31de7 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -12,7 +12,7 @@ A subagent script is derived from a recorded session log by [`deriveReplayScript A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). So a fork child's `.jsonl` begins with the **parent's** events — including the parent's `assistant/chunk` events — and only then carries the child's own turn. -Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. The recorded scenarios are all spawn today, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch. +Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. All recorded scenarios use spawn, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch. ## Decision diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md index d7a4468e53..5caa5a8a66 100644 --- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md @@ -12,7 +12,7 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm- **fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver))来播种子会话,而该 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。 -从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。当时已录制的场景全部是 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。 +从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。所有已录制场景都使用 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。 ## 决策 diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml index 5041596972..11cbbb229f 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.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/testing/2026-06-22-subagent-snapshot-replay.md -2026-06-22-subagent-snapshot-replay.md: bdd4e93ea185c5483c2ff81eba617baaf8cc40b2 -2026-06-22-subagent-snapshot-replay.zh.md: 48691fe4db94febba8d041cf750b69aa9849dd52 +2026-06-22-subagent-snapshot-replay.md: 05ba69ee1927912309b94851d2812f147e81b1e2 +2026-06-22-subagent-snapshot-replay.zh.md: 5501db493ab0c29b69e8bdc08cb3c6fb166b8cfb diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md index bdd4e93ea1..05ba69ee19 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -35,7 +35,7 @@ Child fixtures sort by `createdAt`, which matches call order while siblings run ## Alternatives considered -The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. +The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for the synchronous execution model but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. ### 3. The harness harvests every log, primary-first diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md index 48691fe4db..5501db493a 100644 --- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md +++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md @@ -35,7 +35,7 @@ Status: implemented ## 曾考虑的替代方案 -曾考虑但否决的方案是:**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确)。对当前的同步实现而言更简单,但将「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控则不会。 +曾考虑但否决的方案是:**将父子日志按调用顺序合并**为一份全局脚本(仅在进程内 subagent 执行严格嵌套——父 agent 阻塞等待子 agent——时才正确)。对同步执行模型而言更简单,但将「父阻塞于子」这一不变式固化了进去;未来若引入后台/并发 subagent 就会失效。逐会话键控则不会。 ### 3. harness 收集所有日志,主会话优先 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml index cdb40987a0..d2eb7495d9 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.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/testing/2026-08-13-python-minimal-model-visible-snapshot.md -2026-08-13-python-minimal-model-visible-snapshot.md: 66cfa1ed667d9a60579b0d27ddca2667614d7e1c -2026-08-13-python-minimal-model-visible-snapshot.zh.md: 866988bddab6f257af1bdb7b37e8b238888259dc +2026-08-13-python-minimal-model-visible-snapshot.md: 37c76be93fb4f18fa99ceec7c15d20e572f2dfb3 +2026-08-13-python-minimal-model-visible-snapshot.zh.md: 5c2b0dd5fcd6ec5ea3a68eb884e19b0917bbe0be diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md index 66cfa1ed66..37c76be93f 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md @@ -6,7 +6,7 @@ English | [中文](2026-08-13-python-minimal-model-visible-snapshot.zh.md) ## Problem -The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/jsonrpc-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same. +The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/python-sdk-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same. ## Decision diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md index 866988bdda..5c2b0dd5fc 100644 --- a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/jsonrpc-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。 +Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/python-sdk-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.i18n.yaml similarity index 54% rename from .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml rename to .agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.i18n.yaml index 96fb51ced0..538aa07239 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md -2026-08-04-large-history-pagination-call-stack.md: 28c22121123a227c507c506683ae727d238d98bd -2026-08-04-large-history-pagination-call-stack.zh.md: 57dde9bdc0a4aa52e1af024eb606bf9258430fa7 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md +2026-08-18-session-snapshot-envelope-projection.md: 28f8f7f823b552c68dcb2c54e62f0551e1615134 +2026-08-18-session-snapshot-envelope-projection.zh.md: a8c302c48d3924c3ffd4386a2039d0ea8b7af962 diff --git a/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md new file mode 100644 index 0000000000..28f8f7f823 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md @@ -0,0 +1,35 @@ +# Agent Note: Session snapshot envelope projection + +Status: implemented + +English | [中文](2026-08-18-session-snapshot-envelope-projection.zh.md) + +## Problem + +Committed session snapshots copied the persistence envelope on every body row. The monotonic `seq` and wall-clock `time` fields, or `seq0` and `time0` on packed rows, made a local event insertion renumber or retime a large suffix even when its payloads were unchanged. These fields are required by the durable runtime log, but their repetition made reviewed snapshot diffs describe storage mechanics instead of changed behavior. + +## Decision + +A committed session snapshot is a projection of the persisted JSONL. The envelope projection leaves its first `session` header unchanged, including `version` and `createdAt`; other fixture normalization may still replace volatile header values such as `createdAt`, `id`, and `cwd`. Every body record retains its discriminant, payload, and other top-level fields, while the projection omits `seq`, `time`, `seq0`, and `time0` when present. Nested fields with the same names are payload and remain unchanged. + +Snapshot serialization omits those keys from the parsed body object before writing the line. Snapshot comparison composes ordinary value normalization with that projection, while generic log and stream normalization retains sequence envelopes. Runtime persistence is unchanged. + +Replay's existing `parseSessionLog` entry point accepts the projected fixture and assigns missing sequence fields in memory while decoding. Synthetic event times start at zero; packed `data.dt` values retain the relative gaps already stored in the fixture. Projection stays private to each snapshot writer, and replay synthesis stays inside the replay parser. The repository fixture-layout check uses that parser and retains canonical packed rows. + +## Alternatives considered + +**Change runtime persistence to omit the fields.** Rejected because durable session validation, ordering, and event relationships rely on complete sequence and time envelopes. The instability belongs to the reviewed test representation, not the runtime format. + +**Keep the fields and normalize them to zero or positional numbers.** Rejected because every body row would still carry non-behavioral noise, and positional values would still churn after insertions. + +**Replace numeric payload references with semantic snapshot identifiers.** Rejected for this change because it would alter event payloads and require event-type-specific projection rules. Payload references remain exactly as recorded; the ordinary contiguous runtime sequence lets replay reconstruct the omitted envelope. + +**Introduce a second replay file.** Rejected because the projected session snapshot already retains every payload needed to derive model streams and compare re-persisted behavior. A second fixture would duplicate the transcript and create synchronization work. + +**Publish a shared session-snapshot codec package.** Rejected because this change needs no new product or support-tier capability. Writers only delete four top-level fields while serializing, and replay only needs its existing session-log parser to accept the projected representation. A new package and record-level API would enlarge the change and create an ownership commitment without removing meaningful implementation. + +## Consequences + +Adding, removing, or moving an event no longer rewrites the envelope of every later snapshot row. Snapshot diffs continue to show header changes and all payload changes, including numeric sequence references inside `data`. + +The checked-in file is no longer byte-for-byte valid persistence JSONL. Replay consumers must use `parseSessionLog` instead of passing body rows directly to the storage decoder; snapshot writers apply the projection only at their own file boundary. The synthetic time anchor is not historical wall-clock data; only retained packed gaps carry relative timing. Repository migration and write-back paths enforce the projection so a later recording cannot reintroduce the omitted fields. diff --git a/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.zh.md b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.zh.md new file mode 100644 index 0000000000..a8c302c48d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.zh.md @@ -0,0 +1,35 @@ +# Agent Note:会话快照 envelope 投影 + +Status: implemented + +[English](2026-08-18-session-snapshot-envelope-projection.md) | 中文 + +## 问题 + +签入仓库的会话快照曾复制每一条正文记录的持久化 envelope。普通行的单调 `seq` 与墙钟 `time`,以及打包行的 `seq0` 与 `time0`,会让一次局部事件插入重新编号或计时后面的大段内容,即使其 payload 完全没有变化。这些字段是运行时持久日志所必需的,但在快照中反复出现会让评审 diff 主要描述存储机制,而不是行为变化。 + +## 决策 + +签入仓库的会话快照是持久化 JSONL 的投影。envelope 投影不会修改第一条 `session` header,包括 `version` 与 `createdAt`;其他 fixture 规范化仍可能替换 `createdAt`、`id` 和 `cwd` 等易变 header 值。每条正文记录保留判别字段、payload 和其他顶层字段;投影仅在相应字段存在时省略 `seq`、`time`、`seq0` 与 `time0`。嵌套在 payload 中的同名字段保持不变。 + +快照序列化会在写入每行前从已解析的正文对象上省略这些键。快照比较会组合常规值规范化与该投影;通用日志和 stream 规范化仍保留序号 envelope。运行时持久化不变。 + +回放的现有 `parseSessionLog` 入口接受投影后的 fixture,并在解码时为缺失的序号字段赋值。synthetic 事件时间从零开始;打包行的 `data.dt` 保留 fixture 中已有的相对间隔。投影实现仅位于各快照写入方内部,回放补值实现仅位于回放 parser 内部。仓库 fixture 布局检查使用该 parser,并继续保留规范打包行。 + +## 曾考虑的替代方案 + +**让运行时持久化省略这些字段。** 拒绝,因为持久会话校验、排序和事件关系依赖完整的序号/时间 envelope。不稳定性属于供评审的测试表示,而不是运行时格式。 + +**保留字段并将其归一化为零或位置序号。** 拒绝,因为每条正文记录仍会携带非行为噪声,位置值在插入事件后仍会大面积变化。 + +**把 payload 中的数字引用替换成语义化快照标识。** 本次变更拒绝,因为这会修改事件 payload,并要求按事件类型编写投影规则。payload 引用严格保留录制值;普通、连续的运行时序号足以让回放重建被省略的 envelope。 + +**新增第二份 replay 文件。** 拒绝,因为投影后的会话快照仍保留派生模型流和比较重新持久化行为所需的全部 payload。第二份 fixture 会重复 transcript,并引入同步成本。 + +**发布共享 session-snapshot codec 包。** 拒绝,因为本次变更不需要新的产品或支持层 capability。写入方只需在序列化时删除四个顶层字段,回放只需让现有 session-log parser 接受投影表示。新包和 record-level API 会扩大变更并建立长期归属承诺,却不会删除有意义的实现。 + +## 后果 + +新增、删除或移动事件时,不再重写后续每一条快照记录的 envelope。快照 diff 仍会展示 header 变化和全部 payload 变化,包括 `data` 内的数字序号引用。 + +签入仓库的文件不再是逐字节有效的持久化 JSONL。回放消费方必须使用 `parseSessionLog`,而不能把正文行直接交给 storage decoder;快照写入方仅在自身文件边界应用投影。synthetic 时间锚点不是历史墙钟数据;只有保留的打包间隔表示相对时间。仓库迁移与回写路径强制执行该投影,因此后续录制不会重新引入被省略的字段。 diff --git a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml similarity index 55% rename from .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml rename to .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index b501e15ee1..93a20e387f 100644 --- a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md -2026-08-13-remove-first-run-beta-notice.md: 21396eb9cc3e0e766238115967e354abe1032bd2 -2026-08-13-remove-first-run-beta-notice.zh.md: c7c31b163d4c364b7d1d6ce8d03ac3492a176549 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +2026-08-23-installed-python-wheel-black-box-ci.md: f2b5bd0edeb02a5d72e8010c62c3cfb59ee95c5d +2026-08-23-installed-python-wheel-black-box-ci.zh.md: fb0f5fb2da676f1a24a2630bd45f005f46a3095e diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md new file mode 100644 index 0000000000..f2b5bd0ede --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -0,0 +1,51 @@ +# Agent Note: Installed-wheel Python runtime pull-request validation + +Status: implemented + +English | [中文](2026-08-23-installed-python-wheel-black-box-ci.zh.md) + +## Problem + +The Python SDK unit suite drives fake peers, while the packaged-runtime workflow can run the source SDK against a newly built executable before either Python distribution exists. Its clean virtual environment exercises only the default and MCP cases, and required pull-request CI builds only Linux x64. A source checkout, editable install, mismatched SDK/runtime pair, broken native wheel, platform-specific closure, or real-provider integration can therefore escape the evidence that blocks a merge. + +## Decision + +### Installed artifact boundary + +The required Python runtime workflow builds the pure SDK wheel and each platform runtime wheel before behavior tests. Every native target installs those two local files into a new Python 3.10 virtual environment, changes to a temporary directory outside the repository, unsets `PYTHONPATH` and `DSH_RUNTIME_MODE`, and invokes only the public Python modules plus the packaged executable. + +The black-box harness rejects a non-venv process, repository-relative working directory, source or editable import, unequal distribution versions, an SDK dependency that does not exactly pin the runtime version, an executable outside the installed runtime package, or an executable absent from the runtime distribution record. This provenance check runs before the first agent request, so a behavior pass cannot conceal that the wrong code ran. + +### Keyless behavior + +Every target runs the complete packaged-runtime scenario set after installation. A local SSE model keeps outputs deterministic while the public SDK exercises the default configuration, an external complete configuration, persistent PTY and editor behavior, worker-thread code and workflow execution, ripgrep-backed search, external stdio MCP discovery and execution, model-visible and durable snapshots, JSONL/Zstandard persistence, direct JSON-RPC, and shutdown. A restart snapshot launches two complete SDK runtime processes against one persistence root and pins their isolated model histories, high-level results, and separate durable logs. The installed run replaces the source-SDK pre-wheel run; the executable and wheel are tested together once rather than maintaining two behavior inventories. + +Linux additionally retains its manylinux 2.28 clean-install smoke and GLIBC checks. macOS retains deployment-target and native helper checks. These platform constraints supplement the common black-box behavior rather than substituting for it. + +### Real DeepSeek API + +Trusted pull requests run a second installed-wheel check on every native target with `DEEPSEEK_API_KEY_EXTERNAL`, mapped only into a preflight and the live test step. The preflight fails when the secret is empty, so the provider suite cannot self-skip to green. The test starts the public SDK against `https://api.deepseek.com`, asks the model to write an exact sentinel file through Bash, asks a second turn in the same session to read it, and verifies the external bytes, final responses, completed turn reasons, model-requested tool calls, and the existence and Zstandard framing of its session log. Decoded record content and completed-turn durability are deterministic keyless obligations owned by the restart snapshot rather than inferred from compressed live-provider bytes. + +Fork and Dependabot pull requests never receive the repository secret. Their native jobs run the complete keyless path and skip both secret-bearing steps; `pull_request_target` is forbidden because it would execute untrusted code with the key. + +### Required targets + +The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, and macOS arm64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. Windows has no runtime wheel in the platform manifest and is not claimed by this decision. + +## Existing decisions and supersession + +This decision supersedes the single-target topology in the archived [required Python runtime pull-request validation](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md) while retaining its requirement that the real executable, snapshots, wheels, and clean installation meet before merge. The [single-file Python SDK runtime distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) remains authoritative for SEA packaging, the closed dependency set, native sidecars, wheel tags, and release artifacts. + +## Alternatives considered + +**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the three published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. + +**Run full behavior before wheel construction and keep two small installed smokes.** Rejected because that proves the executable against source imports, then proves too little through the distribution users install. The clean installed environment is the stronger common location for the same scenarios. + +**Use keyless model emulation only.** Rejected because a local SSE endpoint cannot prove authentication, request compatibility, streaming, tool-call interpretation, or a complete turn against the real provider. + +**Expose the key to forked pull requests through `pull_request_target`.** Rejected because arbitrary fork code could exfiltrate the repository secret. Missing credentialed evidence on an untrusted ref is explicit and security-preserving; trusted heads and post-merge provider CI retain the live signal. + +## Consequences + +Every pull request pays for three native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md new file mode 100644 index 0000000000..fb0f5fb2da --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 安装后 Python wheel 黑盒拉取请求验证 + +Status: implemented + +[English](2026-08-23-installed-python-wheel-black-box-ci.md) | 中文 + +## Problem + +Python SDK 单元测试驱动 fake peer,而打包运行时工作流可以在两个 Python distribution 尚未生成时,用源码 SDK 驱动新构建的可执行文件。干净虚拟环境只覆盖默认与 MCP 场景,必需的拉取请求 CI 也只构建 Linux x64。因此,源码 checkout、editable install、不匹配的 SDK/运行时组合、损坏的原生 wheel 包、平台相关闭包或真实提供方集成都可能绕过阻止合并的证据。 + +## Decision + +### 安装产物边界 + +必需的 Python 运行时工作流先构建纯 SDK wheel 包与各平台运行时 wheel 包,再进行行为测试。每个原生目标都把这两个本地文件安装进新的 Python 3.10 虚拟环境,切换到仓库外的临时目录,清除 `PYTHONPATH` 与 `DSH_RUNTIME_MODE`,并且只调用公开 Python 模块与打包后的可执行文件。 + +黑盒测试会拒绝非 venv 进程、仓库内工作目录、源码或 editable import、不相等的 distribution 版本、未精确固定运行时版本的 SDK 依赖、位于已安装运行时包之外的可执行文件,以及未出现在运行时 distribution 记录中的可执行文件。该来源校验发生在首个 agent 请求之前,因此行为通过也不能掩盖实际运行了错误代码。 + +### Keyless 行为 + +每个目标都会在安装后运行完整的打包运行时场景。一个本地 SSE mock 模型提供确定性输出,公开 SDK 则覆盖默认配置、外部完整配置、持久 PTY 与 editor 行为、worker thread 代码与 workflow 执行、基于 ripgrep 的搜索、外部 stdio MCP 发现与执行、模型可见及持久化快照、JSONL/Zstandard 持久化、直接 JSON-RPC 与关闭。Restart 快照针对同一持久化根目录启动两个完整 SDK 运行时进程,并固定其彼此隔离的模型历史、高层结果与独立持久日志。安装后运行取代 wheel 构建前的源码 SDK 运行,因此可执行文件与 wheel 包共同接受一次验证,而不是维护两套行为清单。 + +Linux 另外保留 manylinux 2.28 干净安装冒烟测试与 GLIBC 检查。macOS 保留部署目标与原生 helper 检查。这些平台约束补充共同黑盒行为,不能替代它。 + +### 真实 DeepSeek API + +可信拉取请求会在每个原生目标上运行第二项安装后 wheel 检查,并且只在预检与 live 测试步骤中把 `DEEPSEEK_API_KEY_EXTERNAL` 映射进去。密钥为空时预检失败,因此提供方测试不能通过自行 skip 产生假绿。该测试通过公开 SDK 访问 `https://api.deepseek.com`,要求模型通过 Bash 写入内容精确的 sentinel 文件,再在同一 session 的第二个轮次中读取它,并校验外部文件字节、最终响应、已完成的轮次结束原因、模型请求的工具调用,以及 session 日志存在且采用 Zstandard framing。解码后的记录内容与已完成轮次的持久性是由 restart 快照负责的确定性 keyless 要求,不从压缩后的 live 提供方字节推断。 + +Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 job 运行完整 keyless 路径并跳过两个带密钥的步骤;禁止使用 `pull_request_target`,因为它会让不可信代码带着密钥执行。 + +### 必需目标 + +拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64 与 macOS arm64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。Windows 不在运行时平台 manifest 中,本决策不声称支持它。 + +## Existing decisions and supersession + +本决策取代已归档的[必需 Python 运行时拉取请求验证](../../archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md)中的单目标拓扑,同时保留真实可执行文件、快照、wheel 包与干净安装必须在合并前相遇的要求。[单文件 Python SDK 运行时 distribution](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)仍负责 SEA 打包、封闭依赖集合、原生 sidecar、wheel 包标签与发布产物。 + +## Alternatives considered + +**只保留 Linux x64 必需载体。** 否决:三个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 + +**在 wheel 构建前运行完整行为,并保留两个很小的安装后冒烟测试。** 否决:这只能证明可执行文件配合源码 import 工作,再通过 distribution 证明很少的行为。干净安装环境是在同一批场景中验证用户实际安装内容的更强位置。 + +**只使用 keyless 模型模拟。** 否决:本地 SSE endpoint 不能证明真实提供方的认证、请求兼容性、流式输出、工具调用解释或完整轮次。 + +**通过 `pull_request_target` 向 fork 拉取请求暴露密钥。** 否决:任意 fork 代码都可以窃取仓库密钥。不可信 ref 缺少带凭据证据是明确且保留安全性的结果;可信 head 与合并后提供方 CI 继续提供 live 信号。 + +## Consequences + +每个拉取请求都会承担三个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml index a61435494d..97013fe8df 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.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/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md -2026-07-24-domain-kv-storage-and-workspace.md: ff0db847f6f15130d38a3a001daf8b129311f3a3 -2026-07-24-domain-kv-storage-and-workspace.zh.md: 82f00ddc3b108ed32058586f71eacdbcf5c035c9 +2026-07-24-domain-kv-storage-and-workspace.md: 68e26e6fb55c36c08e1c4d45ed699b05459f1ea6 +2026-07-24-domain-kv-storage-and-workspace.zh.md: 0e837b1fdba3dc7e9764d7a690508a74980f5078 diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md index ff0db847f6..68e26e6fb5 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md) ## Problem -The host's only persistence surface is the session event log (`packages/session/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today: +The host's only persistence surface is the session event log (`packages/session/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two shipped needs exist: - **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Before this design, workspace was only a sidebar visual grouping derived from cwd, with no entity. - **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates. @@ -83,9 +83,9 @@ Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, defa CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT; CREATE TABLE IF NOT EXISTS unit_globals ( unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT; --- 每 unit 每表: +-- One table per unit/table pair: CREATE TABLE IF NOT EXISTS "u__" ( - key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档 + key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document ``` - Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly. @@ -97,8 +97,8 @@ A single implementation, not abstracted; consumers depend on this layer only and ```ts ignore-check export const Config = z.object({ - backend: z.string().required(), // 默认后端名,必填 - routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' } + backend: z.string().required(), // required default backend name + routes: z.dict(z.string()).default({}), // per-domain override: { workspace: 'sqlite' } }) export function apply(ctx: Context, config: Config) { @@ -135,21 +135,21 @@ export function domainTable(schema: ZodType): DomainTabl 6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`. ```ts ignore-check -export interface Domain { +export interface Domain { readonly name: string - readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 + readonly global: { get(): G; set(value: G): Promise } // only when spec.global exists table(name: N): KvTable, ValueOf> } export interface KvTable { - get(key: K): V | undefined // 内存快照,同步 + get(key: K): V | undefined // synchronous in-memory snapshot entries(): IterableIterator<[K, V]> keys(): IterableIterator readonly size: number put(key: K, value: V): Promise - delete(key: K): Promise // false = 本就不存在 + delete(key: K): Promise // false when already absent /** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */ - update(key: K, fn: (current: V) => V): Promise // 缺 key → DomainError('missing-key') + update(key: K, fn: (current: V) => V): Promise // missing key -> DomainError('missing-key') } ``` diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index 82f00ddc3b..0e837b1fdb 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -host 侧唯一的持久化面是 session 事件日志(`packages/session/session-persistence`:仅追加、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求: +host 侧唯一的持久化面是 session 事件日志(`packages/session/session-persistence`:仅追加、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,存在两个已交付需求: - **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。在本设计之前,workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体。 - **session 动态元信息**(可预见的第二个消费方)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。 @@ -83,9 +83,9 @@ Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举, CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT; CREATE TABLE IF NOT EXISTS unit_globals ( unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT; --- 每 unit 每表: +-- One table per unit/table pair: CREATE TABLE IF NOT EXISTS "u__
" ( - key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档 + key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document ``` - unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。 @@ -97,8 +97,8 @@ CREATE TABLE IF NOT EXISTS "u__
" ( ```ts ignore-check export const Config = z.object({ - backend: z.string().required(), // 默认后端名,必填 - routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' } + backend: z.string().required(), // required default backend name + routes: z.dict(z.string()).default({}), // per-domain override: { workspace: 'sqlite' } }) export function apply(ctx: Context, config: Config) { @@ -135,21 +135,21 @@ export function domainTable(schema: ZodType): DomainTabl 6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。 ```ts ignore-check -export interface Domain { +export interface Domain { readonly name: string - readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 + readonly global: { get(): G; set(value: G): Promise } // only when spec.global exists table(name: N): KvTable, ValueOf> } export interface KvTable { - get(key: K): V | undefined // 内存快照,同步 + get(key: K): V | undefined // synchronous in-memory snapshot entries(): IterableIterator<[K, V]> keys(): IterableIterator readonly size: number put(key: K, value: V): Promise - delete(key: K): Promise // false = 本就不存在 + delete(key: K): Promise // false when already absent /** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */ - update(key: K, fn: (current: V) => V): Promise // 缺 key → DomainError('missing-key') + update(key: K, fn: (current: V) => V): Promise // missing key -> DomainError('missing-key') } ``` diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 7432dc0cba..88dadb758f 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.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/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 838d5888429d449144ef59734743bcd9b1a8568e -2026-07-27-session-projection-and-command-log.zh.md: 12e973ffe8ec8caeac90c3743bec794216a5bac8 +2026-07-27-session-projection-and-command-log.md: 1e0dd435d972cfe35d1433417a3884845e384444 +2026-07-27-session-projection-and-command-log.zh.md: dcb077d87eae02c28714fd752606e0966bc70f94 diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 838d588842..1e0dd435d9 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -128,7 +128,7 @@ Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`t 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -The host command executor (`packages/interaction/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged. +The host command executor (`packages/interaction/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep their existing behavior (plan's narration, goal's inject) — that is a domain decision, unchanged. Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired. @@ -156,7 +156,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a **Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code. -**Bounded reverse scan over the log tail (absorber declarations)** — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve. +**Bounded reverse scan over the log tail (absorber declarations).** Rejected: no implementation supports it, it serves only domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row plus forward tail replay — the same recipe as the client's baseline and catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve. **An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear. diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 12e973ffe8..dcb077d87e 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -128,7 +128,7 @@ type UseProjection = { 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } ``` -host 侧命令执行器(`packages/interaction/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent(智能体)的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域继续做它们今天在做的事(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 +host 侧命令执行器(`packages/interaction/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent(智能体)的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据,不是呈现(版式如何编排仍由客户端在渲染时计算,满足「呈现永不入日志」这条红线)。想让模型知道结果的领域保持既有行为(plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。 由于已提交事件会在 mux 流上广播,刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id,发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。 @@ -156,7 +156,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器 **客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。 -**对日志尾部的有界反向扫描(absorber 声明)**——暂不采纳:今天没有任何东西支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 +**对日志尾部的有界反向扫描(absorber 声明)。**不予采纳:现有实现均不支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行加正向尾部回放——与客户端的基线和追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。 **`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index 3aa6aeedd7..ea0fe4b44b 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: c06226c34518a6971a313cc5b7e78c9750a90a17 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 3a8b23f14fd520811fe29a92fab3178c85cb8c7b +2026-07-28-storage-root-and-derived-medium-recovery.md: 1505be1c58d5cf829327b2919113bb2e42798ce7 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 1bab5ab663df1419cc826c3d6acb59bd8bff7de0 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index c06226c345..1505be1c58 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -10,7 +10,7 @@ The persisted projection cache ([note](2026-07-27-session-projection-and-command **Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session/session-persistence-jsonl/src/index.ts). -**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. +**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. ## Proposal diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index 3a8b23f14f..1bab5ab663 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -10,7 +10,7 @@ Status: proposed **文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。 -**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 +**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 ## 提案 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml index ce5f92c4fa..7d447b4b3b 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.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/proposed/architecture/2026-07-29-durable-last-activity-index.md -2026-07-29-durable-last-activity-index.md: 99e50dd40b789db5d896cb7f9e25fa8893b02ae2 -2026-07-29-durable-last-activity-index.zh.md: 3f0ef9a755e437e6647083f43ad55347bd0bdb96 +2026-07-29-durable-last-activity-index.md: d3f21f0b6ddf793ffbc20ddcb2b2f5435c0858d1 +2026-07-29-durable-last-activity-index.zh.md: a690bd87681c0a56cf81446ccf8eedaf105877e7 diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md index 99e50dd40b..d3f21f0b6d 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md @@ -27,7 +27,7 @@ Three questions must be answered before implementation, and none of them is sett **How is the shared predicate owned?** A stored field encodes the rule at write time, where the writer sees one batch, while the attached summary folds a whole log. Both must use one exported event predicate or reducer so new message-source variants cannot make attached and cold ordering disagree. -**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree. +**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at the existing mtime-based accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree. **Is a sidecar acceptable for JSONL?** It reintroduces a second file per session that can disagree with the log, which the single-artifact design avoided. diff --git a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md index 3f0ef9a755..a690bd8768 100644 --- a/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md @@ -27,7 +27,7 @@ Status: proposed **共享谓词由谁拥有?** 已存储字段在写入时编码规则,写入方只看到一个批次,而已附加摘要折叠整份日志。两者必须使用同一个导出的事件谓词或 reducer,避免新的消息来源变体让已附加排序与冷排序发生分歧。 -**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。 +**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持现有基于 mtime 的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。 **对 JSONL 来说伴随文件可以接受吗?** 它重新引入了每会话第二个文件,而该文件可能与日志不一致,这正是单产物设计所避开的。 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index e3567e4124..d327972db7 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.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/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: 6ed58647429182eb34015db246c83701f48bd19b -2026-08-10-unary-apiproxy-remote-migration.zh.md: 84f3b9c637c9fa637dce938f8a3d88292ffe0c56 +2026-08-10-unary-apiproxy-remote-migration.md: c4a308e16bee994df69f16228e859dd88d90c346 +2026-08-10-unary-apiproxy-remote-migration.zh.md: db263e1a7843963cf38082a88405453ee4b66af7 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md index 6ed5864742..c4a308e16b 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -72,7 +72,7 @@ Methods whose signatures contain only branded ids do not invoke Typert object lo ## Client and error behavior -Generated Remote methods return business values and throw an Error whose `cause` contains the existing RPC failure. Client business services own adaptation to their current result/store interfaces. They must settle successful results immediately exactly as they do today so event frames remain idempotent replays rather than the only update path. +Generated Remote methods return business values and throw an Error whose `cause` contains the existing RPC failure. Client business services own adaptation to their current result/store interfaces. They must settle successful results immediately exactly as the existing services do so event frames remain idempotent replays rather than the only update path. Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 84f3b9c637..db263e1a78 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -72,7 +72,7 @@ Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列 ## Client 与错误行为 -生成的 Remote 方法返回业务值,并抛出一个 Error,其 `cause` 包含现有的 RPC 失败。Client 业务服务负责适配到当前的结果/store 接口。它们必须像当前一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。 +生成的 Remote 方法返回业务值,并抛出一个 Error,其 `cause` 包含现有的 RPC 失败。Client 业务服务负责适配到当前的结果/store 接口。它们必须与现有服务一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。 Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.i18n.yaml new file mode 100644 index 0000000000..b53d43cdc7 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.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/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md +2026-08-20-attachment-read-quarantine.md: 28e0f26cee2ec1e257fd4d43b4edc4300e2c6f23 +2026-08-20-attachment-read-quarantine.zh.md: 7f4ceae4e1fe9e656ed762de9828e14145976dc3 diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md new file mode 100644 index 0000000000..28e0f26cee --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.md @@ -0,0 +1,38 @@ +# Agent Note: Quarantine unreadable historical attachments + +Status: proposed + +English | [中文](2026-08-20-attachment-read-quarantine.zh.md) + +## Problem + +An admitted `ImageAttachmentRef` remains in durable history and therefore participates in every later request until compaction replaces it. `AttachmentStore.readImage()` fails with `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, or `ATTACHMENT_READ_FAILED` when the referenced object disappears, fails integrity verification, or cannot be read. The unchanged history then makes every later model request fail on the same object, leaving the session unable to continue even though the remaining messages are usable. This is the unavailable-object case left fail-loud by [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md). + +## Proposal + +A session-backed image-request projection records unreadable references before provider dispatch. `ATTACHMENT_NOT_FOUND` and `ATTACHMENT_CORRUPT` immediately append `attachment/quarantine`; `ATTACHMENT_READ_FAILED` receives one cancellation-aware read retry and appends the same event with a retryable reason if the retry fails. Cancellation and unclassified failures do not quarantine data. + +The quarantine event identifies the attachment and failure class. Projection replaces each quarantined image with deterministic text containing its display name when present, attachment-id prefix, and failure class. Later requests derive the same replacement from the log and skip `readImage()` for that reference, while the original image block remains in append-only history. A request that discovers and records a quarantine reprojects before calling the provider, so the failed read does not become a terminal model-request attempt. + +Explicit recovery calls `readImage()` and appends `attachment/recovered` only after digest and metadata verification succeeds. Projection then restores the original image reference. Missing or corrupt bytes are never overwritten automatically, and clearing quarantine without verification is invalid. + +The shared request-projection consumer owns this policy. Attachment storage continues to report exact read failures, and provider adapters do not invent independent placeholders or recovery state. + +## Alternatives considered + +- **Keep failing every request.** This preserves strict error reporting but makes an otherwise usable durable session permanently unavailable after one storage fault. +- **Delete or rewrite the historical image block.** That loses evidence, violates append-only history, and prevents a repaired content-addressed object from restoring the original request. +- **Catch the error independently in each adapter.** An unlogged placeholder would make replay depend on which adapter and storage state happened to be present, while duplicated policies would drift. +- **Replace missing or corrupt bytes automatically.** The reference names verified immutable content; substituting different bytes under that identity would defeat integrity checking. + +## Acceptance criteria + +- A missing or corrupt historical image produces one durable quarantine transition and a stable placeholder; later model requests do not read that object or fail because of it. +- A general read failure is retried once without ignoring cancellation, then follows the retryable quarantine path. +- Restart and fork reconstruct the same quarantined request from the session log. +- Recovery restores image projection only after the original reference passes complete read verification. +- Package tests cover error classification, idempotent quarantine, cancellation, retry, recovery, and nested tool-result images; a keyless runnable snapshot pins the model-visible placeholder and durable events. + +## Risks + +Quarantine and recovery each change the provider prefix once. The implementation must identify the exact failing reference before recording state and must coordinate concurrent requests so duplicate failures produce one effective transition. Auxiliary calls without a live session cannot record recovery state; their failure policy remains explicit implementation scope rather than an adapter fallback. diff --git a/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md new file mode 100644 index 0000000000..7f4ceae4e1 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 隔离无法读取的历史附件 + +Status: proposed + +[English](2026-08-20-attachment-read-quarantine.md) | 中文 + +## 问题 + +已接纳的 `ImageAttachmentRef` 会留在持久历史中,因此在被压缩替换前都会参与之后的每次请求。引用对象丢失、完整性校验失败或无法读取时,`AttachmentStore.readImage()` 会返回 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT` 或 `ATTACHMENT_READ_FAILED`。未变化的历史随后会让之后每次模型请求在同一对象上失败,使会话无法继续,即使其余消息仍可使用。这是[可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)保留为明确失败的对象不可用情况。 + +## 提案 + +由会话支撑的图片请求投影在分派给提供方之前记录无法读取的引用。`ATTACHMENT_NOT_FOUND` 和 `ATTACHMENT_CORRUPT` 立即追加 `attachment/quarantine`;`ATTACHMENT_READ_FAILED` 先执行一次服从取消信号的读取重试,重试仍失败时追加同一事件并标记为可重试原因。取消和未分类失败不会隔离数据。 + +隔离事件标识附件和失败类别。投影把每张已隔离图片替换为确定性文本,包含可用时的显示名称、附件 ID 前缀和失败类别。之后的请求从日志派生相同替换结果,并跳过该引用的 `readImage()`,原始图片块仍留在仅追加历史中。请求发现并记录隔离后,会在调用提供方前重新投影,因此读取失败不会成为终止性的模型请求尝试。 + +显式恢复会调用 `readImage()`,且仅在内容摘要和元数据校验成功后追加 `attachment/recovered`。投影随后恢复原始图片引用。系统绝不会自动覆盖丢失或损坏的字节,也不允许未经验证就清除隔离。 + +共享请求投影消费方拥有这项策略。附件存储继续报告准确的读取失败,提供方适配器不会各自生成占位或恢复状态。 + +## 考虑过的替代方案 + +- **让每次请求继续失败。** 这保留了严格错误报告,但一次存储故障会让其他部分仍可使用的持久会话永久不可用。 +- **删除或重写历史图片块。** 这会丢失证据、违反仅追加历史,并使修复后的内容寻址对象无法恢复原始请求。 +- **由每个适配器分别捕获错误。** 未记录的占位会让回放取决于当时存在的适配器和存储状态,重复策略也会发生偏差。 +- **自动替换丢失或损坏的字节。** 引用标识经过验证的不可变内容;在该身份下替换成其他字节会破坏完整性校验。 + +## 接受标准 + +- 缺失或损坏的历史图片产生一次持久隔离转换和稳定占位;之后的模型请求不再读取该对象,也不会因它失败。 +- 一般读取失败会在服从取消信号的前提下重试一次,随后进入可重试隔离路径。 +- 重启和 fork 后会从会话日志重建相同的隔离请求。 +- 仅在原始引用通过完整读取校验后,恢复操作才恢复图片投影。 +- 包测试覆盖错误分类、幂等隔离、取消、重试、恢复和嵌套工具结果图片;一个无需密钥的可运行快照钉住模型可见占位和持久事件。 + +## 风险 + +隔离和恢复各会改变一次提供方前缀。实现必须在记录状态前识别准确的失败引用,并协调并发请求,使重复失败只产生一次有效转换。没有活跃会话的辅助调用无法记录恢复状态;它们的失败策略属于明确的实现范围,不能退回到适配器自行处理。 diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml index cf38638484..fff61a1e56 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.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/proposed/feature/2026-06-30-pre-tool-input-rewrite.md -2026-06-30-pre-tool-input-rewrite.md: 63027079a996f2f7b9d2ef87afcd4e64a2691fb1 -2026-06-30-pre-tool-input-rewrite.zh.md: 7697f285506a2df029be3811429dfe621f65951d +2026-06-30-pre-tool-input-rewrite.md: a42b05ac7d2b4d5808807a5810b9e0c4354333a5 +2026-06-30-pre-tool-input-rewrite.zh.md: 8ef07ea7b6ca446b796c3977d5c87a1e7c49917a diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md index 63027079a9..a42b05ac7d 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -16,7 +16,7 @@ In the loop, a tool call's arguments are committed to the log and read by live c 2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`. 3. **Human-facing presentation reads `tool/call.arguments`**: UI renderers pass them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them. -An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode today: it structured-clones and deep-freezes `arguments`, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it. +An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode: it structured-clones and deep-freezes `arguments`, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it. ## Proposal diff --git a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md index 7697f28550..8ef07ea7b6 100644 --- a/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md +++ b/.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md @@ -16,7 +16,7 @@ Status: proposed 2. **`tool/call`** 是持久化的审计记录,在 `ctx.tools.execute()` 之前追加。 3. **面向人类的展示读取 `tool/call.arguments`**:UI 渲染器将这些参数传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台处理方式。 -如果只做执行层面的重写,UI 会显示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前通过以下方式防止这种失败模式:对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。重写设计必须维护这一受保护的身份边界,而非削弱它。 +如果只做执行层面的重写,UI 会显示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表通过以下方式防止这种失败模式:对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。重写设计必须维护这一受保护的身份边界,而非削弱它。 ## 提案 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index da00fd4570..7ce81010a9 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md -2026-07-06-recallable-compaction.md: 377ced0b0ec001287c0c7a3f1900249b30872b95 -2026-07-06-recallable-compaction.zh.md: a78760d6dcaf40f9a8439d61b5ebcdf8a7a97a77 +2026-07-06-recallable-compaction.md: 7309297af84a43a29e03feae815ac8c937a9da6a +2026-07-06-recallable-compaction.zh.md: 890493ae7d6a7b9066dd071b2cc8f642e9c84c86 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 377ced0b0e..7309297af8 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -28,7 +28,7 @@ A committed stub is never rewritten and never re-enters a later compaction regio ### The state checkpoint -One mutable working-memory document (at most one; zero before the first pass), positioned after all stubs and before the retained tail. Each pass rewrites it from the previous state plus this pass's staled content — O(previous + new), under the merge-don't-restate rule already in the summarization prompt — covering decisions, current state, constraints, and next steps. It carries its own footer and a size cap at the scale of today's summary. +One mutable working-memory document (at most one; zero before the first pass), positioned after all stubs and before the retained tail. Each pass rewrites it from the previous state plus this pass's staled content — O(previous + new), under the merge-don't-restate rule already in the summarization prompt — covering decisions, current state, constraints, and next steps. It carries its own footer and a size cap at the scale of the existing summary. An inflation guard bounds the whole pass: if the post-compaction size is not strictly below the pre-compaction size, nothing commits and the turn proceeds; the attempt defers until more stale history accumulates. The guard compares one metric on both sides — provider-reported usage from the request path, falling back to the character estimator on both sides. @@ -50,7 +50,7 @@ Both read `exec.agent.session.events` (the tool-todo access pattern; non-agent c ### Cache and cost -The request prefix after a pass is `[system][stubs…][state][tail]`. Frozen stubs are byte-stable across passes, so the miss begins at the token replacing the previous state checkpoint and stays O(new chunks + state + tail) — against position zero today. Recall output lands at the tail, leaving the prefix untouched. Per-pass summarize input is roughly twice today's plus an m·S background term, bounded by a `chunkTokens` floor (a small multiple of the state cap) and a validated `stubTokens`/`chunkTokens` ratio ceiling; a shared-prefix input layout (preamble, then the byte-identical pass-start state, slice content in the tail) lets sibling calls earn cached-rate rereads. +The request prefix after a pass is `[system][stubs…][state][tail]`. Frozen stubs are byte-stable across passes, so the miss begins at the token replacing the previous state checkpoint and stays O(new chunks + state + tail) — instead of the baseline's position-zero miss. Recall output lands at the tail, leaving the prefix untouched. Per-pass summarize input is roughly twice the baseline input plus an m·S background term, bounded by a `chunkTokens` floor (a small multiple of the state cap) and a validated `stubTokens`/`chunkTokens` ratio ceiling; a shared-prefix input layout (preamble, then the byte-identical pass-start state, slice content in the tail) lets sibling calls earn cached-rate rereads. ### Packaging @@ -78,7 +78,7 @@ Deferred until observation calls for them: ## Alternatives considered -- **Staged delivery** (ship recall tools alone over today's backend; gate the checkpoint split on observed recall usage) — rejected: untrained models under-use any new tool, so the gate would measure training absence rather than design value, while the training side needs the complete mechanism to build environments against; the pre-release window is when persisted-format changes are cheapest; and the cache economics are first-party knowledge, not a hypothesis awaiting telemetry. The implementation still lands as stacked PRs with the recall tools first — construction order, not a decision gate. +- **Staged delivery** (ship recall tools alone over the existing backend; gate the checkpoint split on observed recall usage) — rejected: untrained models under-use any new tool, so the gate would measure training absence rather than design value, while the training side needs the complete mechanism to build environments against; the pre-release window is when persisted-format changes are cheapest; and the cache economics are first-party knowledge, not a hypothesis awaiting telemetry. The mechanism still introduces the recall tools before the checkpoint split; that is construction order, not a decision gate. - **All-frozen full-size summaries, no state checkpoint** — rejected: unbounded permanent-prefix growth, self-accelerating toward thrashing, with nothing left to re-prioritize. - **Pure stubs, no state checkpoint** — rejected: presumes the model knows what it is missing; fails on unknown unknowns. - **LLM aging/consolidation of frozen chunks** — rejected as a routine mechanism: summary-of-summary loss and frozen-prefix churn; the code-only rollup is its surviving form, deferred. @@ -102,9 +102,9 @@ Deferred until observation calls for them: ## Risks -- **Recall is a learned behavior**: untrained models will under-use it, and the bench report exists to track the gap while training closes it. Until then the state checkpoint keeps the floor at today's summary quality. +- **Recall is a learned behavior**: untrained models will under-use it, and the bench report exists to track the gap while training closes it. Until then the state checkpoint keeps the floor at the existing summary quality. - **Unknown unknowns remain**: a detail absent from summaries and keywords draws no recall. Recall converts "unreachable even when suspected" into "reachable when suspected". - **The stub directory occupies attention**: dozens of stable index cards per request may dilute focus; the bench measurement in the acceptance criteria tracks it against `compaction-basic`. -- **Cost**: per-pass summarize input is roughly twice today's; short sessions sit near today's cost and quality, and the design pays off with session length. +- **Cost**: per-pass summarize input is roughly twice the baseline input; short sessions sit near the baseline cost and quality, and the design pays off with session length. - **State drift and division-of-labor leakage** are observable through the handoff probe and stub review; their counters are specified follow-ups. - **Two backends** are a maintenance burden; the seam contract and the shared recall consumer bound it, and the bench comparison decides the default over time. diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index a78760d6dc..890493ae7d 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -28,7 +28,7 @@ Status: proposed ### 状态检查点 -系统维护一份可变的工作记忆文档(最多一份;第一次压缩前为零份),位于所有存根之后、保留尾部之前。每一轮根据先前状态与本轮变为陈旧的内容重写它,成本为 O(previous + new);过程遵守摘要提示词中已有的「合并而不重复陈述」规则,并覆盖决策、当前状态、约束和后续步骤。它带有自己的页脚,其大小上限与当前摘要处于同一量级。 +系统维护一份可变的工作记忆文档(最多一份;第一次压缩前为零份),位于所有存根之后、保留尾部之前。每一轮根据先前状态与本轮变为陈旧的内容重写它,成本为 O(previous + new);过程遵守摘要提示词中已有的「合并而不重复陈述」规则,并覆盖决策、当前状态、约束和后续步骤。它带有自己的页脚,其大小上限与现有摘要处于同一量级。 膨胀保护会约束整轮操作:如果压缩后大小没有严格小于压缩前大小,就不提交任何内容,并继续当前轮次;本次尝试延后至积累更多陈旧历史后再进行。保护逻辑在两侧比较同一项度量:优先使用请求路径上提供方报告的用量;如果不可用,则两侧都回退为字符估算器。 @@ -50,7 +50,7 @@ Status: proposed ### 缓存与成本 -一轮后的请求前缀为 `[system][stubs…][state][tail]`。冻结存根在各轮之间逐字节稳定,因此缓存缺失从替换先前状态检查点的 token 才开始,规模保持 O(new chunks + state + tail),而当前实现会从位置零开始缺失。回溯输出落在尾部,不会改变前缀。每轮摘要输入大约是当前实现的两倍,另加一个 m·S 背景项;该成本受到 `chunkTokens` 下限(状态上限的小倍数)以及经过校验的 `stubTokens`/`chunkTokens` 比例上限约束。共享前缀输入布局依次为前导内容、逐字节相同的本轮开始状态、位于尾部的切片内容,使同级调用可以按缓存费率重复读取。 +一轮后的请求前缀为 `[system][stubs…][state][tail]`。冻结存根在各轮之间逐字节稳定,因此缓存缺失从替换先前状态检查点的 token 才开始,规模保持 O(new chunks + state + tail),而基线实现会从位置零开始缺失。回溯输出落在尾部,不会改变前缀。每轮摘要输入大约是基线输入的两倍,另加一个 m·S 背景项;该成本受到 `chunkTokens` 下限(状态上限的小倍数)以及经过校验的 `stubTokens`/`chunkTokens` 比例上限约束。共享前缀输入布局依次为前导内容、逐字节相同的本轮开始状态、位于尾部的切片内容,使同级调用可以按缓存费率重复读取。 ### 打包方式 @@ -78,7 +78,7 @@ Status: proposed ## 考虑过的替代方案 -- **分阶段交付**(先在当前后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。实现仍以堆叠 PR(Pull Request)方式落地,并先交付回溯工具,但这只是构建顺序,不是决策门槛。 +- **分阶段交付**(先在现有后端之上单独交付回溯工具;观察到回溯使用后,再决定是否拆分检查点):不予采纳。未经训练的模型会低频使用任何新工具,因此该条件测量的是训练缺失,而不是设计价值;训练侧需要完整机制来构建环境;预发布阶段修改持久化格式的成本最低;缓存经济性则属于第一方已经掌握的知识,不是等待遥测验证的假设。该机制仍先引入回溯工具,再拆分检查点;这只是构建顺序,不是决策门槛。 - **只保留冻结的全尺寸摘要,不设状态检查点**:不予采纳,因为永久前缀会无界增长、自我加速并最终发生颠簸,而且没有任何内容可以重新确定优先级。 - **只保留纯存根,不设状态检查点**:不予采纳,因为这假定模型知道自己缺少什么,在面对未知的未知时会失败。 - **由 LLM 老化/整合冻结分片**:不作为常规机制,因为摘要的摘要会丢失信息,并使冻结前缀频繁变化;其保留下来的形式是由代码汇总,且延后实现。 @@ -102,9 +102,9 @@ Status: proposed ## 风险 -- **回溯属于学习到的行为**:未经训练的模型会低频使用它,bench 报告会持续追踪这项差距,直至训练弥合问题。在此之前,状态检查点会让质量下限保持在当前摘要水平。 +- **回溯属于学习到的行为**:未经训练的模型会低频使用它,bench 报告会持续追踪这项差距,直至训练弥合问题。在此之前,状态检查点会让质量下限保持在现有摘要的质量水平。 - **未知的未知仍然存在**:如果某项细节既未出现在摘要中,也未出现在关键词中,就不会触发回溯。回溯把「即使已经怀疑也无法触达」变成「怀疑时可以触达」。 - **存根目录会占用注意力**:每次请求中包含数十张稳定的索引卡,可能稀释模型关注点;验收标准中的 bench 度量会将其与 `compaction-basic` 对比。 -- **成本**:每轮摘要输入大约是当前实现的两倍;短会话的成本和质量接近当前水平,而设计收益随会话长度增长。 +- **成本**:每轮摘要输入大约是基线输入的两倍;短会话的成本和质量接近基线水平,而设计收益随会话长度增长。 - **状态漂移与职责分工泄漏**可以通过交接探针和存根评审观察;对应措施已列为后续事项。 - **两个后端**会扩大维护范围;seam 约定和共享回溯消费方会限制这一范围,bench 对比则用于逐步决定默认实现。 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index e45bb53e99..4affd8145c 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: d2c75d30f47076577534c239d4f1ed222851c45f -2026-08-04-task-surface.zh.md: a428175422f853072750487e5463fb614e8f1d00 +2026-08-04-task-surface.md: 03f79dbc7d40957603885c97883a2de1bb5405d7 +2026-08-04-task-surface.zh.md: 63bb286bd8095f6eb0d3b0d366e62a838ae8bdc9 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index d2c75d30f4..03f79dbc7d 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -6,7 +6,7 @@ English | [中文](2026-08-04-task-surface.zh.md) ## Problem -Some tasks are awkward to finish through alternating prose messages. Comparing several options, reordering a plan, reviewing a table, or filling a small set of related fields all work better as one structured interaction. Today an agent can describe such an interaction, but it cannot ask the Web client to render one without adding a permanent product component or generating executable Client Plugin code. +Some tasks are awkward to finish through alternating prose messages. Comparing several options, reordering a plan, reviewing a table, or filling a small set of related fields all work better as one structured interaction. An agent can describe such an interaction, but it cannot ask the Web client to render one without adding a permanent product component or generating executable Client Plugin code. Those two workarounds put ownership in the wrong place. Product-specific components require a new trigger and release for every task shape. Generated code has far more authority and lifecycle cost than a one-turn form needs. It also makes the presentation, rather than the user's conclusion, the durable artifact. @@ -274,7 +274,7 @@ The implementation depends on the existing message log, canonical tool output, t ## Risks -The first component set may be either too small for useful tasks or broad enough to become a weak application framework. Usage evidence should decide additions; v1 has no expression language or network behavior. +The first component set may be either too small for useful tasks or broad enough to become a weak application framework. Usage evidence should decide additions; the initial set has no expression language or network behavior. The Task Surface Markdown policy gives up inline images, media, and automatic link previews. Ordinary links remain useful, but only an explicit user activation may navigate or start a request. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index a428175422..63bb286bd8 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -有些任务很难通过交替发送文本消息来完成。比较多个选项、调整计划顺序、审阅表格,或填写一小组关联字段,都更适合在一次结构化交互中处理。目前,agent(智能体)可以描述这类交互,但若不增加永久的产品组件或生成可执行的客户端插件代码,就无法要求 Web 客户端渲染这类交互。 +有些任务很难通过交替发送文本消息来完成。比较多个选项、调整计划顺序、审阅表格,或填写一小组关联字段,都更适合在一次结构化交互中处理。agent(智能体)可以描述这类交互,但若不增加永久的产品组件或生成可执行的客户端插件代码,就无法要求 Web 客户端渲染这类交互。 这两种变通方案的职责归属都不合理。产品专用组件要求每种任务形态都新增触发方式并发布新版本。对于只需一个轮次的表单,生成代码所拥有的权限和生命周期成本都远超实际需要。这样做还会把展示界面而非用户结论变成持久产物。 @@ -274,7 +274,7 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor ## 风险 -第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;v1 不提供表达式语言或网络行为。 +第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;初始组件集不提供表达式语言或网络行为。 Task Surface 的 Markdown 策略舍弃行内图片、媒体和自动链接预览。普通链接仍有用,但只有用户显式操作后,才可以导航或发起请求。 diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml index d13e5e6f1d..b84fc76dbd 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.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/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md -2026-07-04-prune-dead-core-spine-api.md: 64c9bb00a7f26463aea6767018da8814a13133ec -2026-07-04-prune-dead-core-spine-api.zh.md: 0666fa8b6b6228b97ade7ba91eb214db82f6bf94 +2026-07-04-prune-dead-core-spine-api.md: ae6ea2d763c1f84b32d1a24bae32f412c77c9976 +2026-07-04-prune-dead-core-spine-api.zh.md: 8e76827de10e119ed1e0d9fbf8a44e8eaf21575c diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md index 64c9bb00a7..ae6ea2d763 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md @@ -47,7 +47,7 @@ Remove or demote every row as one bounded coordinated public-surface cleanup. Up ## Alternatives considered -**Keep test conveniences and self-contained results public.** Public helpers can make white-box tests convenient, self-contained result fields can look ergonomic, and future embedders might want the concrete loop or enumeration methods. Those benefits are hypothetical; today they make every implementation and document explain states that no shipped caller can observe. A real consumer can introduce the smallest contract it needs, with its ownership and failure semantics known. +**Keep test conveniences and self-contained results public.** Public helpers can make white-box tests convenient, self-contained result fields can look ergonomic, and future embedders might want the concrete loop or enumeration methods. Those benefits are hypothetical; retaining them makes every implementation and document explain states that no shipped caller can observe. A real consumer can introduce the smallest contract it needs, with its ownership and failure semantics known. **Keep every catalogued member for model-written mounts.** The self-referential toolset is a real generic consumer route, not generated-doc noise. Its value comes from an accurate, composable service API, however, not from preserving duplicate fields or incoherent argument pairs indefinitely; each catalogued contraction above removes a fact available elsewhere on the same execution, agent, or result and updates the API reference in the same change. diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md index 0666fa8b6b..8e76827de1 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md @@ -47,7 +47,7 @@ Status: proposed ## 曾考虑的替代方案 -**保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更易用,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;当前它们让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小约定,其所有权和失败语义明确。 +**保留测试便利函数和自包含的结果字段为公开。** 公开辅助函数可以让白盒测试更方便,自包含的结果字段看起来更易用,未来的嵌入者可能需要具体循环类或枚举方法。这些好处是假设性的;保留它们会让每处实现和文档都要解释没有已交付调用者能观察到的状态。真正的消费方可以引入它所需的最小约定,其所有权和失败语义明确。 **保留所有 catalog 成员以供模型编写的 mount 使用。** 自引用工具集是一条真实的通用消费路径,而非生成文档的噪音。然而,它的价值来自准确、可组合的服务接口,而非无限期保留重复字段或不一致的参数对;上述每一项 catalog 收缩都移除了在同一次执行、同一个 agent(智能体)或同一结果中其他位置已可获得的事实,并在同一变更中更新 API 参考。 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml index de8021afc9..6f4de4d9ae 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.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/proposed/simplification/2026-07-19-make-jsonrpc-directional.md -2026-07-19-make-jsonrpc-directional.md: be0d8003e066b8695fb413d5a546e43694bf2225 -2026-07-19-make-jsonrpc-directional.zh.md: d070983b14898078b481a930eb70e7f6db53714a +2026-07-19-make-jsonrpc-directional.md: 6afb75eaf39ef8ce32b8885fde0e42cd388a46ec +2026-07-19-make-jsonrpc-directional.zh.md: a6c6abb7bdb5b3c03746c93604660ab86a4ce9d6 diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md index be0d8003e0..6afb75eaf3 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -28,7 +28,7 @@ Return the settled outcome directly from `session/prompt` as `{ status, reason } ## Alternatives considered -**Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer today. +**Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer in the shipped protocol. **Keep `session.finished` for streaming clients.** Turn settlement is not incremental data: the request response already marks the same boundary and follows all earlier notifications on the ordered stream. A second terminal notification creates two representations that clients must reconcile. diff --git a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md index d070983b14..a6c6abb7bd 100644 --- a/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -28,7 +28,7 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协 ## 备选方案 -**为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但当前没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 +**为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 **为流式客户端保留 `session.finished`。** 轮次结束不是增量数据:请求响应已经标识同一个边界,并且在有序流中位于先前所有通知之后。第二条终止通知会产生两种结果表示,迫使客户端进行协调。 diff --git a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml deleted file mode 100644 index dca75b57c9..0000000000 --- a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: e995ec4e20f41cc4377131c0f6e8672e0588ccea -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: aac6b9772e2ae9ec8d72eaa569f3a84020df94d7 diff --git a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md deleted file mode 100644 index e995ec4e20..0000000000 --- a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: Evaluate landstrip before building a Windows sandbox launcher - -Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan - -English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md) - -## Problem - -The [sandbox decision](../../implemented/feature/2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty and plans to fill it with "a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template" — an estimated ~1,500-line new repo (the landlock-run subtree is ~1,460 lines of C/TS/scripts/tests plus docs and CI) authored and maintained in-house. - -Since that note was written, a maintained third-party runner has appeared: `@landstrip/landstrip` (npm, actively developed, Rust core with prebuilt per-platform `optionalDependencies`) covers Landlock + seccomp on Linux, Seatbelt on macOS, and AppContainer/restricted-user on Windows, with JSON/YAML policy input and a trap-fd denial-reporting channel. It is exec-wrapped like bwrap, so it fits the chain's `confine(argv)` shape without touching the Linux/macOS rungs. - -## Proposal - -When the Windows sandbox phase is picked up, evaluate wrapping landstrip's Windows backend as the `win32` chain runner before authoring an in-house AppContainer launcher repository. The evaluation must answer: - -- **Probe synthesis.** landstrip has no `--probe`; the chain's functional-probe contract would have to be synthesized from a trap run. -- **Dialect mapping.** Denial and runner-failure stderr dialects, and fail-closed exit-code classification, need explicit mapping into the chain's vocabulary. -- **License.** The binaries are LGPL-2.1-or-later; distribution review is required before it enters the shipped closure. -- **Source and build record.** Each in-house launcher binary is byte-pinned to a native CI build of a ~300-line reviewable C file; landstrip is a single-maintainer Rust binary set. For the *existing Linux rung* that trade is already settled — do not swap it ([sandbox note](../../implemented/feature/2026-07-06-sandbox.md) and the launcher's own migration away from a Rust dependency). For a rung we have not built, weighing third-party maintenance against a second in-house native repo is a genuinely open question. - -## Alternatives considered - -- **Build the in-house AppContainer launcher as planned.** Still the default if the evaluation fails on license, source/build auditability, or probe fit; the cost is owning a second native security launcher repo indefinitely. -- **Swap the Linux Landlock rung to landstrip too.** Rejected outright: sandbox correctness is a security invariant, the current launcher has reviewable C source and binaries byte-pinned to native CI builds, and it already migrated away from a Rust dependency for exactly this reason. - -## Acceptance criteria - -- Before any Windows-rung implementation starts, an evaluation records the probe, dialect, license, source repository, release process, and binary build answers, and the go/no-go is added to the sandbox note's deferred-phases plan. - -## Risks - -- Single-maintainer supply chain in a security-critical position — the reason this is an evaluation gate, not an adoption decision. -- The package is young; its API and packaging may churn before the Windows phase starts, so re-verify against the live registry then. diff --git a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md deleted file mode 100644 index aac6b9772e..0000000000 --- a/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ /dev/null @@ -1,34 +0,0 @@ -# Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip - -Status: rejected — landstrip 未经实战检验(驳回时问世仅数天,只有一名维护者,GitHub 星标约 48 个);关系到安全不变量的依赖必须经过广泛采用的验证,因此 win32 层级维持自研启动器的原计划 - -[English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 - -## 问题 - -[沙箱决策](../../implemented/feature/2026-07-06-sandbox.zh.md)将 `PLATFORM_CHAINS.win32` 留空,并计划用「AppContainer/受限令牌(restricted-token)家族的一个约束运行器,按 `node-addon-landlock-run` 模板从其独立仓库发布」来填充——一个估计约 1,500 行、需要自研编写并维护的新仓库(landlock-run 子树约为 1,460 行 C/TS/脚本/测试,外加文档与 CI)。 - -自那份决策记录写成以来,出现了一个持续维护的第三方运行器:`@landstrip/landstrip`(npm 包,活跃开发中,Rust 内核,附带按平台预构建的 `optionalDependencies`)覆盖 Linux 上的 Landlock + seccomp、macOS 上的 Seatbelt,以及 Windows 上的 AppContainer/受限用户,支持 JSON/YAML 策略输入和基于 trap-fd 的拒绝上报通道。它与 bwrap 一样采用 exec 包装方式,因此无需触碰 Linux/macOS 层级即可契合链的 `confine(argv)` 形态。 - -## 提案 - -当 Windows 沙箱阶段启动时,在动手编写自研 AppContainer 启动器仓库之前,先评估将 landstrip 的 Windows 后端包装为 `win32` 链运行器。评估必须回答: - -- **探测合成。** landstrip 没有 `--probe`;链所要求的功能探测约定必须从一次 trap 运行中合成出来。 -- **方言映射。** 拒绝与运行器失败两类 stderr 方言,以及失败关闭的退出码分类,都需要显式映射到链的词汇中。 -- **许可证。** 其二进制文件采用 LGPL-2.1-or-later 许可;在进入随产品发布的依赖闭包之前需要先做分发审查。 -- **源码与构建记录。** 每个自研启动器二进制都逐字节锁定到一个约 300 行、可完整评审的 C 文件的原生 CI 构建;而 landstrip 是单一维护者手中的一组 Rust 二进制文件。对*既有的 Linux 层级*而言,这笔权衡早有定论——不要替换它(见[沙箱 Agent Note](../../implemented/feature/2026-07-06-sandbox.zh.md)以及该启动器自身移除 Rust 依赖的迁移记录)。而对一个我们尚未构建的层级,在第三方维护与第二个自研原生仓库之间如何取舍,是一个真正悬而未决的问题。 - -## 曾考虑的替代方案 - -- **按原计划构建自研 AppContainer 启动器。** 若评估在许可证、源码/构建可审计性或探测契合度上不通过,这仍是默认选项;代价是要长期维护第二个原生安全启动器仓库。 -- **把 Linux Landlock 层级也换成 landstrip。** 直接否决:沙箱正确性是安全不变量,当前启动器有可审阅的 C 源码,其二进制逐字节锁定到原生 CI 构建,而且它正是出于这一原因才迁移摆脱了 Rust 依赖。 - -## 验收标准 - -- 在任何 Windows 层级实现开始之前,先有一份评估记录下探测、方言、许可证、源代码仓库、发布流程和二进制构建问题的答案,并把「采用/不采用」(go/no-go)结论加入沙箱 Agent Note 的延后阶段计划。 - -## 风险 - -- 处于安全关键位置的单一维护者供应链——这正是本提案定为一道评估门禁、而非采用决定的原因。 -- 该包尚且年轻;在 Windows 阶段启动之前其 API 与打包方式可能反复变动,届时需对照在线注册表重新核验。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 3fc0ba2154..d51608534c 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c834142bccaeca2f7407c984767f5aa0af88bc22 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 043f87809cba0ad5bdae18eaef345d55ad3c41ec +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 14873d3296461357fe6582386f394bc1a5bd9483 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4bdac0712972eb3cb85c8e721b39146995401385 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c834142bcc..14873d3296 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses. - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file. - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries. -- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file whose binaries are byte-pinned to native CI builds and that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface whose releases are harder to match to reviewed source. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.) +- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file whose binaries are byte-pinned to native CI builds and that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface whose releases are harder to match to reviewed source. The [Windows ACL decision](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) records the same rejection for the shipped Windows rung. - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain. - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined four times on js-yaml (vendored include, app-boot, apps/cli, `scripts/verify-cordis-config.ts`) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 043f87809c..4bdac07129 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。 -- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审的 C 文件,其二进制逐字节锁定到原生 CI 构建,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合有更大的审计面,其发布更难与已审阅源码对应。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md)——landstrip 未经实战检验。) +- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审的 C 文件,其二进制逐字节锁定到原生 CI 构建,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合有更大的审计面,其发布更难与已审阅源码对应。[Windows ACL 决策](../../implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md)为已交付的 Windows 层级记录了同一否决。 - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了四次(vendor 收录的 include、app-boot、apps/cli、`scripts/verify-cordis-config.ts`),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的可保留注释的 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。 diff --git a/.agents/skills/dsh-archive-agent-notes/agents/openai.yaml b/.agents/skills/dsh-archive-agent-notes/agents/openai.yaml deleted file mode 100644 index 5df6cbdb56..0000000000 --- a/.agents/skills/dsh-archive-agent-notes/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Archive Agent Notes" - short_description: "Audit and freeze low-value Agent Notes" - default_prompt: "Use $dsh-archive-agent-notes to audit Agent Notes, archive low-future-value implemented records, and delete low-value rejected records." diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e79f11cdc7..510e01f3d5 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -25,6 +25,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md). 5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)). 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. +7. **Client UI copy is locale-owned.** Reject product text embedded in JSX, templates, helper returns, accessibility attributes, or primitive defaults. Require typed dictionary keys, the standard `t` seat or explicit localized props, `verify-client-ui-i18n`, and behavior evidence in each affected locale; preserve user/model/wire data and code tokens verbatim. ## Manual checks diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 91dd42ae8a..e4a7dede73 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -5,7 +5,7 @@ description: Use when publishing, updating, moving, or removing DeepSeek Harness # Synchronizing the DeepSeek Harness Documentation Site -Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. +Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. The build additionally emits a raw-Markdown twin of every route (page URL minus any trailing slash, plus `.md`; index routes also get a parent-level alias) and a root `llms.txt` index; both derive from the same manifest and projector, so publishing, moving, or removing a page updates them automatically and `docs:build` fails when one is missing. Repository translations follow the sibling pairing contract: English `foo.md`, Chinese `foo.zh.md`, and `foo.i18n.yaml` live together. Never create `zh-CN/` or other locale directories for website content. The site route trees are independent of that source layout: `foo.zh.md` projects to the root route and `foo.md` projects to the matching `/en/` route. diff --git a/.agents/skills/dsh-doc-site-sync/agents/openai.yaml b/.agents/skills/dsh-doc-site-sync/agents/openai.yaml deleted file mode 100644 index 9f4909f258..0000000000 --- a/.agents/skills/dsh-doc-site-sync/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "DSH Documentation Site Sync" - short_description: "Publish repository docs through the DSH website manifest" - default_prompt: "Use $dsh-doc-site-sync to publish or update a DeepSeek Harness documentation page on the website." diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 1ea836b8ee..c69b032d42 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -27,7 +27,7 @@ Apply the standard's authoring order to every human-facing document in scope. Do Then check constraints that make placement expensive or wrong: -- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn. +- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn. Verbatim code blocks are byte-exact across the pair: copy a corrected fence into both files instead of translating its comments independently. - Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source. - Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit ``), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings still need a manual grep when their output never reaches gate-scanned Markdown. - A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 2a10999c27..ef7f387c4e 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-find-simplifications -description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.' +description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, remove redundant comments or implementation-heavy documentation, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, added-then-removed, or hand-rolled-where-a-dependency-exists surfaces.' --- # Finding DeepSeek Harness Simplifications @@ -44,6 +44,13 @@ If subagents are unavailable, simulate the same breadth yourself. Do not let the Start with the largest production-code deltas. A broad simplification audit that stops after obvious unused symbols can miss the files where duplicated lifecycle or defensive machinery carries most of the cost. +## Simplify Prose With The Code + +Treat comments and documentation as maintained surface area. Apply [dsh-prose-standard](../dsh-prose-standard/SKILL.md) when a survey includes prose. + +- Delete comments that restate code or explain behavior owned elsewhere; keep required local contracts. +- Keep docs at their owning level; omit implementation details and rare cases unless they change a maintained contract. + ## Audit Trust And Lifecycle Boundaries For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml deleted file mode 100644 index 4a38ea4da8..0000000000 --- a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "DSH Pre-Push Checks" - short_description: "Run the relevant DeepSeek Harness checks before push" - default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch." diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 069ba91fdd..42f9bbab9e 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -23,7 +23,7 @@ Always exclude `vendor/` from discovery, review, and edits, even when the reques Also exclude `.agents/notes/archived/` from prose review and edits. Archived Agent Notes are frozen snapshots; inspect an exact target only to understand a historical inbound citation, never to modernize its prose or outbound links. -Treat generated catalogs, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate the artifact. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules), update the counterpart minimally, and re-record the pair. +Treat generated catalogs, snapshots, and fixtures as derivative. Trace every consumer before editing: source JSDoc may also feed a generated model-visible catalog. Edit the owning source or scenario first, then regenerate every derivative. When a generator extracts a summary from owner prose, make the extracted sentence complete for that surface. Bilingual pairs have no permanent owner: either language may be the authored side for an update. Follow the [lightweight routine path](../../../docs/AGENTS.md#writing-rules), update the counterpart minimally, and re-record the pair. ## Preserve the complete proposition @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/.agents/skills/dsh-prose-standard/agents/openai.yaml b/.agents/skills/dsh-prose-standard/agents/openai.yaml deleted file mode 100644 index 52b47167dc..0000000000 --- a/.agents/skills/dsh-prose-standard/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "DSH Prose Standard" - short_description: "Write concise prose without losing contracts" - default_prompt: "Use $dsh-prose-standard to audit a specified repository scope for required, complete, and concise prose." diff --git a/.agents/skills/dsh-prose-standard/references/examples.md b/.agents/skills/dsh-prose-standard/references/examples.md index c6bff65cbd..85d2b33db1 100644 --- a/.agents/skills/dsh-prose-standard/references/examples.md +++ b/.agents/skills/dsh-prose-standard/references/examples.md @@ -146,6 +146,8 @@ Keep the consequence of order, a surprising scope rule, or a security boundary. Wording that reaches a model is behavior, but duplication still drifts. Exactness belongs at the owner. +A prose-only audit may identify suspect wording but must not silently change it when no owning runnable snapshot exists. Leave it unchanged and report the deferral, or expand the authorized change to include the owner scenario and required GUI evidence. + ## Generated summaries must stand alone **Over-trimmed:** “Approval request and policy service.” The owner explains policy order and audit logging later, but the catalog exports only its first sentence. diff --git a/.agents/skills/dsh-translate-docs/agents/openai.yaml b/.agents/skills/dsh-translate-docs/agents/openai.yaml deleted file mode 100644 index 8f02948105..0000000000 --- a/.agents/skills/dsh-translate-docs/agents/openai.yaml +++ /dev/null @@ -1,7 +0,0 @@ -interface: - display_name: "DSH Extended Doc Translation" - short_description: "Run the full bilingual documentation workflow manually" - default_prompt: "Use $dsh-translate-docs to run the extended bilingual-document workflow for the specified pair." - -policy: - allow_implicit_invocation: false diff --git a/.agents/skills/dsh-trim-cot-leakage/SKILL.md b/.agents/skills/dsh-trim-cot-leakage/SKILL.md index 7fad4d50c2..8e7ea1714d 100644 --- a/.agents/skills/dsh-trim-cot-leakage/SKILL.md +++ b/.agents/skills/dsh-trim-cot-leakage/SKILL.md @@ -38,8 +38,8 @@ Unaided citation passes fail in both directions by deleting durable references a ## Workflow -1. Scope and exclusions per [dsh-prose-standard](../dsh-prose-standard/SKILL.md): require an explicit scope; never touch `vendor/`, `.agents/notes/archived/`, or recorded fixtures and snapshots — recorded model output and sealed history keep their original voice. -2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge found cases the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. -3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording. +1. Scope and exclusions per [dsh-prose-standard](../dsh-prose-standard/SKILL.md): require an explicit scope; never touch `vendor/` or `.agents/notes/archived/`. Recorded fixtures and snapshots are derivatives, not prose targets: change the owning source or scenario and regenerate them only when an authorized behavior change requires new evidence. +2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), calibrating each probe against a known positive and a near-miss negative before trusting its output, then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge found cases the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. +3. Fix owner-first per surface: generated catalogs → trace every consumer, fix the source JSDoc or generator template, then regenerate all derivatives; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual prose → update the counterpart minimally and re-record it through the [lightweight routine](../../../docs/AGENTS.md#writing-rules); bilingual fences → copy the corrected verbatim block byte-for-byte into both sides per [dsh-doc-standards](../dsh-doc-standards/SKILL.md), then re-record the pair; model- or user-visible strings → route through [dsh-prose-standard](../dsh-prose-standard/SKILL.md) and change only with owning behavior evidence, otherwise leave unchanged and report the deferral. 4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance. 5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). diff --git a/.agents/skills/dsh-trim-cot-leakage/references/examples.md b/.agents/skills/dsh-trim-cot-leakage/references/examples.md index 9afc22c209..0d850fde15 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/examples.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/examples.md @@ -176,6 +176,22 @@ Replace the hedge with the actual bound and the failure behavior when it is exce Working-language fragments and session separators are transcription residue. The Figma frame name stays: external provenance that resolves outside the repo by design. +### Authoring-language slip inside a paired fence + +**Leaked in both files:** `// 更新这里 before returning` inside a verbatim code block. + +**Fixed in both files:** `// Update this before returning.` + +Correct the block once and copy that byte-exact fence into both language files. Translating the code comment differently in the Chinese counterpart breaks the pairing contract even when both comments are individually fluent. + +## Behavior-visible candidates + +**Suspect:** An exported JSDoc sentence says "available for now," and a generator copies that sentence into a model-visible catalog. + +**Wrong:** Rewrite only the source sentence during a prose-only purge, or hand-edit only the generated catalog. + +**Right:** Trace the source's generated fan-out, update the owner, regenerate every derivative, and update the owning runnable snapshot. For a GUI string, include the repository-required behavior evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral. + ## Keeps ### Issue references are durable on every surface @@ -208,6 +224,12 @@ The measurement pins the constant against uninformed retuning, and "measured" is "Old" and "new" here name two live runtime objects during handover, not repository states. The change-narration ban is about repo history, not lifecycle vocabulary. +### Runtime natural time is not a version stamp + +**Keep:** "What is today's date?" + +The prompt asks about the runtime clock; "today" does not contrast repository states. Because the text reaches a model, any rewrite still requires its owning behavior evidence. + ## Overcorrection traps Every trap below shipped in the original purge and was caught in review. Enumerate a passage's propositions before trimming it. diff --git a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md index 9b1c79054c..4802413c4d 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md @@ -7,25 +7,34 @@ Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. - Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes. - Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked wording as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. - Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the first line, which matches code patterns, stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. -- A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative. +- Bound complete phrases. `\bthis PR\b` must match "this PR adds" without matching "this project", "this process", or "this provider". +- A zero-hit pattern proves nothing until it matches a known positive, and a noisy pattern proves nothing until it rejects a near-miss negative. Calibrate both before trusting a corpus result. +- Target authoring-language probes at the opposite-language surface: search Chinese residue in otherwise-English Markdown and code comments/JSDoc, and search Chinese change narration within `*.zh.md`. A generic ASCII search for English residue in Chinese prose is too noisy around code and identifiers; compare the prose additions against their counterpart instead. ## English battery ```sh rg -n --hidden '\(decision \d|\(audit [A-Z]\d|design §|plan §|design ledger|\(B ruling|\bP-I\b|\bW\d\b|\bT\d\b' ... -rg -n --hidden -i 'this PR|this branch|this stack|later PR|previous commit|this commit' ... -rg -n --hidden -i 'used to |no longer|previously|the old |was renamed|was moved' ... +rg -n --hidden -i '\bthis PR\b|\bthis branch\b|\bthis stack\b|\blater PRs?\b|\bprevious commits?\b|\bthis commit\b' ... +rg -n --hidden -i '\bused to\b|\bno longer\b|\bpreviously\b|\bthe old\b|\bwas renamed\b|\bwas moved\b' ... rg -n --hidden -i '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ... rg -n --hidden -i 'rejected in review|review round|reviewer|as of v\d' ... rg -n --hidden -i 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ... rg -n --hidden '§\d' ... ``` -## Chinese battery +## Chinese batteries ```sh -rg -n --hidden '设计稿|评审|上一?轮|旧版|老的|不再|以前|本版|遗留|私有' ... -rg -n --hidden '(^|[^a-zA-Z])端([^a-zA-Z]|$)' --glob '*.md' ... +# Change or review narration in Chinese counterparts. +rg -n --hidden '评审|上一?轮|旧版|老的|不再|以前|本版|遗留' --glob '*.zh.md' ... + +# Chinese authoring-language slips in English Markdown. +rg -n --hidden '设计稿|评审|上一?轮|旧版|老的|不再|以前|本版|遗留|私有|(^|[^a-zA-Z])端([^a-zA-Z]|$)' --glob '*.md' --glob '!*.zh.md' ... + +# Chinese authoring-language slips in English code comments and JSDoc. +rg -n --hidden '(^[[:space:]]*(//|/\*|\*)|//|/\*)[^\r\n]*(设计稿|评审|上一?轮|旧版|老的|不再|以前|本版|遗留|私有|端)' --glob '*.{ts,tsx,js,jsx,mjs,cjs,css}' ... +rg -n --hidden '#[^\r\n]*(设计稿|评审|上一?轮|旧版|老的|不再|以前|本版|遗留|私有|端)' --glob '*.py' ... ``` ## Known false-positive families @@ -38,6 +47,6 @@ Judged and kept during the purge; expect them again: - **`v1` as protocol or path segment** — `/v1/chat` endpoints and wire-format names are identifiers, not version stamps. - **`§N` with a committed owner** — external standards (RFC 9110 §10.1.5) and committed docs that own their §-numbering stay citable by section. - **Contrastive "actually" and noun "wait"** — ordinary English, not hedging; no committed line probes them, so they surface only when you extend the battery with broader hedging patterns. -- **"Today" in generated timestamps and CLI output samples** — recorded output keeps its voice. +- **Runtime "today" and recorded timestamps** — prompts or tests that ask for the current date use natural time, not a repository version stamp; recorded CLI output keeps its voice. Wording that reaches a model or user still follows the behavior-evidence rule before any edit. - **本版本 in zh prose** — a legitimate rendering of "this release" in versioned-artifact contexts; the banned indexical is 本版 as a bare stamp mirroring "this cut". - **Alternatives-considered sections** — "rejected" inside an Agent Note's genre slot is the sanctioned home, not review choreography. diff --git a/.agents/skills/record-browser-gif/agents/openai.yaml b/.agents/skills/record-browser-gif/agents/openai.yaml deleted file mode 100644 index 720f55f7dc..0000000000 --- a/.agents/skills/record-browser-gif/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Record Browser GIF" - short_description: "Record and optimize local browser demo GIFs" - default_prompt: "Use $record-browser-gif to record this browser flow as a verified local GIF." diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index ba17869f29..b9ba5e148e 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -21,10 +21,14 @@ on: required: false default: false ci: - description: Run as the required Linux x64 Python runtime pull-request check. + description: Run as the required all-target Python runtime pull-request check. type: boolean required: false default: false + secrets: + DEEPSEEK_API_KEY_EXTERNAL: + description: Real DeepSeek API key for trusted installed-wheel pull-request tests. + required: false workflow_dispatch: inputs: targets: @@ -243,13 +247,6 @@ jobs: echo "exe=$exe" >> "$GITHUB_OUTPUT" echo "wheel=$wheel" >> "$GITHUB_OUTPUT" - - name: Full-turn SDK, executable snapshot, and direct-binary smoke - run: >- - uv run --python 3.10 --group test --project python/sdk - python scripts/smoke-python-runtime.py - --scenario all - --exe "${{ steps.runtime.outputs.exe }}" - - name: Build release-shaped runtime wheel run: >- python scripts/build-python-release.py @@ -273,10 +270,53 @@ jobs: "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ "dist-python/$SDK_WHEEL" \ "dist-python/$RUNTIME_WHEEL" - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ - --scenario sdk-default - "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ - --scenario sdk-mcp + + - name: Run installed-wheel keyless black-box tests + run: | + set -euo pipefail + blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox" + mkdir -p "$blackbox_root" + cd "$blackbox_root" + env -u PYTHONPATH -u DSH_RUNTIME_MODE \ + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ + --scenario all \ + --installed-wheel + + - name: Preflight installed-wheel real API test + if: >- + inputs.ci + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + run: | + set -euo pipefail + if [ -z "${DEEPSEEK_API_KEY:-}" ]; then + echo "::error::DEEPSEEK_API_KEY_EXTERNAL is empty; the installed-wheel real API test cannot self-skip." + exit 1 + fi + + - name: Run installed-wheel real API black-box test + if: >- + inputs.ci + && (github.event_name != 'pull_request' + || !(github.event.pull_request.head.repo.fork + || github.event.pull_request.user.login == 'dependabot[bot]')) + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} + DEEPSEEK_BASE_URL: https://api.deepseek.com + run: | + set -euo pipefail + blackbox_root="$RUNNER_TEMP/dsh-sdk-blackbox-live" + mkdir -p "$blackbox_root" + cd "$blackbox_root" + env -u PYTHONPATH -u DSH_RUNTIME_MODE \ + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" \ + "$GITHUB_WORKSPACE/scripts/smoke-python-runtime.py" \ + --scenario sdk-live \ + --installed-wheel - name: Check Linux GLIBC requirements if: runner.os == 'Linux' @@ -314,8 +354,10 @@ jobs: docker run --rm -e RUNTIME_WHEEL -e SDK_WHEEL -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install "/work/dist-python/$SDK_WHEEL" "/work/dist-python/$RUNTIME_WHEEL" - /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default - /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp + mkdir -p /tmp/dsh-sdk-manylinux-smoke + cd /tmp/dsh-sdk-manylinux-smoke + env -u PYTHONPATH -u DSH_RUNTIME_MODE /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default --installed-wheel + env -u PYTHONPATH -u DSH_RUNTIME_MODE /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp --installed-wheel ' - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml new file mode 100644 index 0000000000..5f67b97893 --- /dev/null +++ b/.github/workflows/build-preview-cloudflare.yml @@ -0,0 +1,170 @@ +name: Build PR preview + +# Every push to a pull request publishes that pull request's preview to +# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The +# upload carries build products only: the workflow never grants the deployment +# platform access to this repository's sources. + +on: + pull_request: + types: [opened, synchronize, reopened] + +# Within one pull request the newest build wins. Across pull requests there is +# nothing to serialize: each uploads to its own branch alias, so two deployments +# never contend for the same URL. +concurrency: + group: build-preview-cloudflare-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +env: + PRIMARY_NODE_VERSION: '24' + # Cloudflare Pages project receiving the upload. Its preview deployments are + # the surface the Access application protects; the project's production branch + # is deliberately a name no deployment uses, so no unprotected URL exists. + CF_PROJECT: dsh-build-preview + # CI runs must never report to the production telemetry endpoint baked into + # apps/cli/cordis.yml (AppCLIEntry disables the row when set). + DSH_TELEMETRY_DISABLED: '1' + +jobs: + preview: + runs-on: dsh-ubuntu-24-04-16core + name: cloudflare pages preview + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Read-only: the preview lane consumes the default-branch cache without + # putting cache upload on its own path. + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # apps/web consumes workspace packages as built lib products, and + # build:preview packs the image through the packer's installed bin + # (lib/bin.js), so neither half exists before the full build runs. + - name: Build workspace + run: pnpm run build + + - name: Build the preview page and pack the VFS image + env: + DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }} + run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview + + # Sourcemaps carry complete sources and stay off the deployment platform. + # index.html is the served page, which cannot boot without a host + # injecting window.__DSH_BOOT__; replacing it with the worker page makes + # the deployment root the usable entry instead of a page that never boots. + - name: Shape the upload + run: | + find apps/web/dist -name '*.map' -delete + cp apps/web/dist/preview.html apps/web/dist/index.html + + - name: Upload to Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx --yes wrangler@4 pages deploy apps/web/dist \ + --project-name "$CF_PROJECT" \ + --branch "pr-${{ github.event.pull_request.number }}" \ + --commit-dirty=true + + # The image is what a worker boot fails on first and least visibly, so the + # run only passes once the protected URL serves it as gzip bytes. Three + # facts are asserted, each with its own failure meaning: + # 200 Access admitted the request; a 302 means the + # Access policy is missing its Service Auth rule + # for this token + # no content-encoding the platform did not claim transport + # compression, which would make the browser + # decode the body and leave the worker's + # DecompressionStream inflating a plain tar + # gzip magic 1f 8b the bytes really are the gzip member the + # packer wrote + # Accept-Encoding is sent because a browser sends it; the assertion is + # about what the platform does with a body that is already compressed. + - name: Verify the protected deployment serves the image + env: + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + run: | + url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev" + image="$url/preview/vfs-image.tar.gz" + code=000 + for attempt in 1 2 3 4 5; do + code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \ + -H 'Accept-Encoding: gzip' \ + -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \ + -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \ + "$image" || echo 000) + echo "attempt $attempt: HTTP $code" + if [ "$code" = "200" ]; then break; fi + sleep 10 + done + if [ "$code" != "200" ]; then + echo "the protected image URL answered $code, not 200" + head -20 headers.txt + exit 1 + fi + if grep -qi '^content-encoding:' headers.txt; then + echo "the platform declared transport compression on an already-compressed image:" + grep -i '^content-encoding:' headers.txt + exit 1 + fi + magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n') + if [ "$magic" != "1f8b" ]; then + echo "image does not start with the gzip magic number: $magic" + exit 1 + fi + echo "image served as $(wc -c < image.bin) gzip bytes" + + # The alias URL follows from the pull request number, so it is stable + # across redeploys and worth stating once. The marker makes the comment + # idempotent: a pull request opened before this workflow existed never + # sees an `opened` event, and every later push must not restate the URL. + - name: Comment the preview URL + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + marker='' + existing=$(gh pr view "$PR" --json comments \ + --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty") + if [ -n "$existing" ]; then + echo "preview URL already commented: $existing" + exit 0 + fi + # The marker sits on its own line: markdown renders no link on a + # line that opens with a raw HTML comment. + printf '%s\n\n%s\n' \ + "$marker" \ + "[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \ + | gh pr comment "$PR" --body-file - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df3c4e7e8d..1f02c2919f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,22 +294,23 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # One native target makes the complete release-shaped Python path required - # without duplicating platform-independent behavior across the release matrix. - # The reusable builder owns the executable, snapshot, wheel, clean-install, - # GLIBC, and manylinux checks; release validation retains all native targets. + # The reusable builder owns each published executable, wheel, clean-install, + # keyless black-box, and trusted real-API path. All native release targets are + # required because a platform wheel cannot be validated by another carrier. python-runtime: if: github.event_name == 'pull_request' - name: python runtime / release-shaped Linux x64 + name: python runtime / release-shaped matrix uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ci: true + secrets: + DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # The required pull-request Windows signal: the two blocking win32 surfaces - # (workspace build, production site) execute with real, checksum-verified - # Windows Node under Wine on standard hosted Linux. The independent - # windows-native job below keeps the complete native-kernel inventory — + # The pull-request Windows signals cover complementary hosts. The two fast + # win32 toolchain surfaces (workspace build, production site) execute with + # real, checksum-verified Windows Node under Wine on standard hosted Linux. + # The windows-native job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — # on real Windows. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same @@ -396,13 +397,12 @@ jobs: if: always() run: wineserver -k 2>/dev/null || true - # Every pull request also gets a real Windows-kernel signal. This job keeps - # its own unmasked conclusion but is deliberately absent from - # all-checks-passed.needs, so it never delays or changes that required - # verdict. Under normal operation it runs on the hosted larger runner; under - # Windows failover (DSH_CI_FAILOVER_WINDOWS=selfhosted) it retargets onto the - # in-house self-hosted Windows pool. Dependabot PRs are excluded from the - # self-hosted pool and stay queued for the hosted runner — see the failover + # Every pull request also gets a real Windows-kernel signal. Its unmasked + # conclusion is a dependency of all-checks-passed, so failure, cancellation, + # or omission blocks the required verdict. Under normal operation it runs on + # the hosted larger runner. DSH_CI_FAILOVER_WINDOWS=selfhosted retargets it + # onto the in-house self-hosted Windows pool. Dependabot PRs are excluded + # from the self-hosted pool and stay queued for the hosted runner — see the failover # runbook. This Windows switch is independent of the Linux # DSH_CI_FAILOVER_LINUX variable that retargets the three required Linux jobs # and the all-checks-passed verdict above. @@ -416,12 +416,16 @@ jobs: name: windows node 24 / native complete timeout-minutes: 120 env: - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '8' + # Partitioned coverage finishes before the heavy uninstrumented gate; + # the latter can use four workers without competing with sixteen shards. + DSH_COVERAGE_MAX_WORKERS: '12' + DSH_COVERAGE_PARTITIONS: '16' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' - DSH_GATE_CONCURRENCY: '4' + # After the threshold merge, the heavy gate overlaps lightweight + # observational checks within this post-coverage worker budget. + DSH_GATE_CONCURRENCY: '8' DSH_PUBLINT_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 @@ -436,7 +440,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm + dest: ${{ runner.temp }}/setup-pnpm-js - uses: actions/setup-node@v6 with: @@ -457,10 +461,10 @@ jobs: # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in - # `needs`. The required Wine job is listed as `windows`; `windows-native` is - # deliberately absent so its independent result never delays or changes this - # verdict. (`needs` cannot reach across workflow files; the master-only jobs in - # ci-master.yml are intentionally not part of this PR verdict.) + # `needs`, including both the Wine `windows` job and the real-kernel + # `windows-native` job. (`needs` cannot reach across workflow files; the + # master-only jobs in ci-master.yml are intentionally not part of this PR + # verdict.) # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including @@ -471,14 +475,15 @@ jobs: # provisioning — and under Linux failover it follows the same selector as # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch - # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the - # required Linux workers and runs on the vm-backup pool. + # (DSH_CI_FAILOVER_LINUX), not the Windows one, because this bookkeeping job + # itself runs on Linux; the native dependency resolves its Windows pool + # independently. runs-on: >- ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} - needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows] + needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows, windows-native] if: always() && github.event_name == 'pull_request' steps: - name: Fail if any needed job did not succeed diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 78bdafdf5b..8bac404b2d 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -1,17 +1,16 @@ +# Publish the documentation website to GitHub Pages. This workflow is manual-only +# (workflow_dispatch) and intentionally does not listen to pull_request or push: +# the site presents a released snapshot, so publication is an explicit act from a +# dsh-v* tag and must never appear as a PR check. `release:verify` rejects every +# other ref, and the github-pages environment repeats that restriction as a +# deployment tag policy with required reviewers. +# +# The build signal does not depend on this workflow. Every pull request builds +# the production site through `check:ci:static`, and ci-master.yml builds it +# again on master. name: Deploy documentation on: - push: - branches: [master] - paths: - - '.github/workflows/docs-pages.yml' - - 'docs/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'pnpm-workspace.yaml' - - 'scripts/project-doc-site.ts' - - 'scripts/project-doc-site.spec.ts' - - 'website/**' workflow_dispatch: concurrency: @@ -24,7 +23,11 @@ permissions: env: PRIMARY_NODE_VERSION: '24' # Projected source links target the public repository, whose history can - # differ from this workflow's source repository. + # differ from this workflow's source repository. This stays on master rather + # than following the dispatched tag: that repository advances only to each + # release commit, so its master never carries unreleased work, and it retains + # only the most recent tags — following the tag would leave every projected + # source link on an older deploy unresolvable. DOCS_REPOSITORY_REF: master # CI runs must never report to the production telemetry endpoint baked # into apps/cli/cordis.yml (AppCLIEntry disables the row when set). @@ -37,7 +40,11 @@ jobs: contents: read pages: read steps: + # Complete history: the release scripts read tags. - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - uses: pnpm/action-setup@v4 @@ -49,6 +56,14 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + # One definition of "released" for the site and the npm sequence: this + # rejects every ref that is not a dsh-v* tag naming the version this tree + # carries. + - name: Verify release version + env: + RELEASE_PUBLISH: 'true' + run: pnpm run release:verify --family dsh + - name: Configure Pages id: pages uses: actions/configure-pages@v6 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9c377deb79..099908616c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -60,8 +60,9 @@ jobs: if: >- github.event_name != 'pull_request' || !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]') - # Bounded file parallelism (DSH_E2E_MAX_WORKERS), 120s/test, retry 2. 45m - # still bounds retry storms against a slow API while the happy path fans out. + # Profile e2e files can each own several complete dsh subprocess trees, so + # four file workers preserve process/PTY headroom. Tests retain 120s/test + # and retry 2; 45m still bounds retry storms against a slow API. timeout-minutes: 45 steps: - uses: actions/checkout@v6 @@ -115,6 +116,6 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com - DSH_E2E_MAX_WORKERS: 14 + DSH_E2E_MAX_WORKERS: 4 DSH_EXAMPLE_MODE: lib run: pnpm run test:e2e diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index f5b9c63c4b..d888d17a8a 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -1,9 +1,9 @@ name: Release (Python) -# A PR labeled python-release-dry-run or a manual run with publish=false builds -# and validates the complete release without registry credentials. Publication -# is accepted only from a manual run on the matching python-v* tag when the -# private publisher-repository identity and public-PyPI switch are configured. +# A manual run with publish=false builds and validates the complete release +# without registry credentials. Publication is accepted only from a manual run +# on the matching python-v* tag when the private publisher-repository identity +# and public-PyPI switch are configured. on: workflow_dispatch: inputs: @@ -12,8 +12,6 @@ on: required: true type: boolean default: false - pull_request: - types: [labeled] permissions: contents: read @@ -27,7 +25,6 @@ concurrency: jobs: build: name: Build four wheels - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run' uses: ./.github/workflows/build-exe-for-python-sdk.yml with: targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 diff --git a/.oxlintrc.json b/.oxlintrc.json index 70f53fd4cd..1f11a9e989 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -57,6 +57,14 @@ } ], "no-useless-constructor": "error", + "no-restricted-properties": [ + "error", + { + "object": "crypto", + "property": "randomUUID", + "message": "browsers withhold crypto.randomUUID outside secure contexts (plain-HTTP LAN pages); mint through @deepseek-ai/dsh-util-crypto instead" + } + ], "typescript/await-thenable": "error", "typescript/ban-ts-comment": [ "error", @@ -316,6 +324,21 @@ "rules": { "@stylistic/quotes": "off" } + }, + { + "files": [ + "packages/experimental/webworker-runtime/src/node/**/*.ts", + "packages/experimental/webworker-runtime/src/storage/memory.ts", + "packages/experimental/webworker-runtime/src/module-system/module-loader.ts", + "packages/experimental/webworker-runtime/src/transport/synthetic-http.ts" + ], + "rules": { + "typescript/require-await": "off", // Async faces Node and Cordis define (fs promises, the module seam) reject rather than throw; the VFS beneath them never awaits. + "typescript/no-extraneous-class": "off" // Node constructs these (`new Script()`, `new Worker()`), so a stub that refuses must still be a class. + }, + "plugins": [ + "typescript" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index 28208b2c87..3fa1ce77e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,14 @@ # AGENTS.md -DeepSeek Harness is a plugin-based agent harness on vendored Cordis: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; follow [docs/AGENTS.md](docs/AGENTS.md) for documentation. +DeepSeek Harness is an all-plugin agent harness on vendored Cordis. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; follow [docs/AGENTS.md](docs/AGENTS.md) for documentation. ## Pre-release stance: foundation over blast radius -**Remove this section at the first tagged release.** With no external consumers, prefer the correct foundation over compatibility shims: rename or repackage freely and update every reference together. Backends reject old on-disk formats. SQLite uses monotonic `SCHEMA_VERSION`; `dsh-session` keeps `SESSION_FORMAT_VERSION` at `0` with no compatibility promise. +**Remove at the first tagged release.** Until then, prefer correct foundations over compatibility shims and update every reference together. Backends reject old disk formats; SQLite increments `SCHEMA_VERSION`, while `dsh-session` holds `SESSION_FORMAT_VERSION` at `0` without a compatibility promise. + +## Application launch + +Node apps launch only through `dsh` profiles; application-package bins, demos, and SDK argv escape hatches are forbidden. The private Python runtime is the sole temporary exception. [Architecture](docs/architecture.md#application-launch) owns scope and deferred artifact rename; `pnpm run verify-application-entrypoints` enforces it. ## Repository layout @@ -17,7 +21,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM capability: Service Definition/Consumer + DeepSeek providers e2b/ E2B POC: sandbox + FS/subprocess adapters shell/ bash capability: Service Definition + local/pwsh providers + shell Consumers - subprocess/ subprocess capability + local process-tree provider + subprocess/ subprocess capability + local process-tree provider + shared Win32 library terminal/ persistent sessions fs/ filesystem capability + policy lsp/ language-server capability @@ -28,6 +32,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// subagent/ subagent capability: Service Definition + providers + delegation Consumers bundle/ installable dsh --profile patch-layer bundles workflow/ workflow capability + worker-thread provider + tool Consumer + webhook/ webhook ingress todo/ todo_write tool plan/ plan mode as logged state preset/ per-session agent composition from preset cordis.yml files @@ -40,9 +45,9 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// credentials/ credential/authorization capabilities + env/.env provider acp/ automation-only Agent Client Protocol server interaction/ approval/interaction capabilities, permission, commands, ask-user - boot/ shared app-bin glue - sdk/ JSON-RPC protocol, server, and TypeScript client - examples/ demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) + boot/ shared profile/application boot glue + sdk/ JSON-RPC protocol, server, TypeScript client, and private Python carrier + examples/ reusable demo bundles (agent-spine) experimental/ private prototypes excluded from official releases support/ dev/test infrastructure util/ zero-dependency utilities @@ -82,15 +87,11 @@ pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ### Host sandbox failures -When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test. +If a required `gh`, `pnpm`, build, test, or generator command fails because the sandbox blocks credentials, network, IPC, watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation. Require sandbox evidence; never bypass test failures or the product sandbox. ### Run relevant checks locally -Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass. - -- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. -- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. -- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). +Before pushes, use [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) to choose the smallest diff-covering checks; after `gh stack sync`, validate immediately and never merge before they pass. Report commands only. Match evidence to its surface: focused behavior tests, model/user snapshots, `doc-sync`, build/hygiene plus built smokes for published paths, and real-API e2e for provider behavior. CI owns exhaustive coverage and the platform matrix; run them locally only by request, for CI diagnosis, or for an irreducibly repository-wide change. `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env @@ -117,15 +118,16 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). - **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. -- Do not comment on facts obvious from code. +- **Keep comments local.** Do not restate code, explain distant behavior unless locally required, or expand unrelated comments ([rationale](.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. -- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. +- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)). -- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). +- **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. diff --git a/README.i18n.yaml b/README.i18n.yaml index 1550aac8ca..1609bf9532 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 9ccd27b8934449bd0d2311317dc38aee5a5c0cdc -README.zh.md: 103acdefa6bcc161e71224c8aea94a262ff96a67 +README.md: 9847d1fc35d5eceea484c229872dc8614ba3654a +README.zh.md: 6838b7c712e181dc44ca467225adf2aadf7ad947 diff --git a/README.md b/README.md index 9ccd27b893..9847d1fc35 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ DeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek It uses an architecture where **everything is a plugin**, and is powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper). +Documentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) + ## Developer preview DeepSeek Harness is currently in _developer preview_ and is iterating rapidly. **THERE WILL BE COMPATIBILITY-BREAKING CHANGES.** +Review the [safety notice](SAFETY.md) before running the project. + ## Run ### Run from `npm` diff --git a/README.zh.md b/README.zh.md index 103acdefa6..6838b7c712 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,10 +6,14 @@ DeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的 它采用**一切皆插件**的架构,并由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。 +文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/) + ## 开发者预览 DeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。**未来将出现破坏兼容性的变更。** +运行本项目前,请阅读[安全说明](SAFETY.zh.md)。 + ## 运行 diff --git a/SAFETY.i18n.yaml b/SAFETY.i18n.yaml new file mode 100644 index 0000000000..6010fd783e --- /dev/null +++ b/SAFETY.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 SAFETY.md +SAFETY.md: 2b76f00e0619ee69553afdc507df361080f4d3ac +SAFETY.zh.md: 6f7dae47b3e47ddfb155adb9c8c868b516d75360 diff --git a/SAFETY.md b/SAFETY.md new file mode 100644 index 0000000000..2b76f00e06 --- /dev/null +++ b/SAFETY.md @@ -0,0 +1,27 @@ +# Safety + +English | [中文](SAFETY.zh.md) + +## Experimental status + +DeepSeek Harness is experimental developer-preview software. It has not undergone a security audit and must not be treated as secure or production-ready. + +The project can execute model-generated code and commands, load third-party plugins, and access the network, processes, credentials, and files made available to it. Incorrect model output, defects, misconfiguration, malicious input, or untrusted plugins may damage the host computer, modify or delete files, disclose data or credentials, or cause other unintended effects. + +## Sandbox limitations + +Sandboxing, approval prompts, and permission controls can reduce risk, but they do not guarantee isolation or prevent damage. Even correctly enforced restrictions cannot protect resources that the project is allowed to access. + +Do not rely on DeepSeek Harness as the sole security control for untrusted workloads. + +## Responsible use + +- Run the project with the least privileges and access required. +- Prefer a disposable virtual machine, container, or dedicated environment. +- Keep backups of files that the project can access. +- Do not expose sensitive credentials or data unless you accept the risk. +- Review plugins, configuration, and proposed commands before allowing them to run. + +## No warranty or liability + +Use DeepSeek Harness at your own risk. The software is provided without warranty under the [MIT License](LICENSE). To the maximum extent permitted by applicable law, the authors and copyright holders are not responsible for damage to computers, loss or disclosure of data, loss of files, or other harm arising from use of the project. diff --git a/SAFETY.zh.md b/SAFETY.zh.md new file mode 100644 index 0000000000..6f7dae47b3 --- /dev/null +++ b/SAFETY.zh.md @@ -0,0 +1,27 @@ +# 安全 + +[English](SAFETY.md) | 中文 + +## 实验性状态 + +DeepSeek Harness 是实验性的开发者预览软件。它尚未接受安全审计,不得视为安全或可用于生产环境的软件。 + +本项目可以执行模型生成的代码与命令、加载第三方插件,并访问向其开放的网络、进程、凭据和文件。错误的模型输出、缺陷、配置错误、恶意输入或不可信插件可能损坏宿主计算机、修改或删除文件、泄露数据或凭据,或造成其他非预期影响。 + +## 沙箱限制 + +沙箱、审批提示与权限控制可以降低风险,但不保证隔离,也不能保证防止损害。即使限制得到正确执行,也无法保护本项目获准访问的资源。 + +不要把 DeepSeek Harness 当作不可信工作负载唯一的安全控制措施。 + +## 负责任地使用 + +- 仅向本项目授予所需的最小权限和访问范围。 +- 优先在一次性虚拟机、容器或专用环境中运行。 +- 备份本项目可以访问的文件。 +- 除非你接受相关风险,否则不要向其暴露敏感凭据或数据。 +- 在允许运行前检查插件、配置和拟执行命令。 + +## 不提供保证,不承担责任 + +请在充分了解相关风险的前提下使用 DeepSeek Harness。本软件依照 [MIT License](LICENSE) 提供,不附带任何保证。在适用法律允许的最大范围内,对于使用本项目造成的计算机损坏、数据丢失或泄露、文件丢失或其他损害,作者和版权持有人不承担责任。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 295eb1868f..f2637fe699 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -39,6 +39,8 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT | +| [`@octokit/webhooks`](https://github.com/octokit/webhooks.js) | MIT | | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | @@ -51,7 +53,11 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`clsx`](https://github.com/lukeed/clsx) | MIT | | [`commander`](https://github.com/tj/commander.js) | MIT | @@ -81,6 +87,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | +| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | @@ -134,8 +141,10 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | @@ -148,6 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index fbea2bc740..977871533e 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 9a8d722b044ed5d8e31e3c27e54f8c9ef0839f82 -README.zh.md: c092414e2d15e90133d8ea27f0af5cadbe527b22 +README.md: d59b8264093dafb0160893c942371a4daa942c14 +README.zh.md: 6a209f5ad64b38c138ae1712a05ed9e840d9a74f diff --git a/apps/cli/README.md b/apps/cli/README.md index 9a8d722b04..d59b826409 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,18 +2,20 @@ English | [中文](README.zh.md) -The `dsh` command is the product launcher for profiles: ordered stacks of plugin-bundle patch layers under the user's own overrides. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. ## Entry modes | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | +| `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. | | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. | | `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## App arguments @@ -29,14 +31,14 @@ dsh --help # the launcher's own help ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once. The tree composes over an empty root: - each bundle's patch in `dsh.profile.bundles` order - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml` - then `--patch` overlays -Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. +Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c092414e2d..6a209f5ad6 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,18 +2,20 @@ [English](README.md) | 中文 -`dsh` 是 DeepSeek Harness 中用于启动 profile 的命令;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | +| `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 | | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 | +| `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 | | `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -运行命令时所在的目录将作为默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## 应用参数 @@ -31,14 +33,14 @@ dsh --help # the launcher's own help ## Profile -profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile` 和其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。 +profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。 配置树以空根为起点,依次叠加以下配置层: - `dsh.profile.bundles` 中各组合包的 patch - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml` - `--patch` 指定的覆盖层 -`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 +`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cbc5e9c356..e56b0be22c 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -14,8 +14,12 @@ flowchart LR cfg --> plugin_dsh_base_hmr plugin_dsh_base_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_dsh_base_llm + plugin_dsh_base_deepseek_llm_api_extensions["deepseek-llm-api-extensions
@deepseek-ai/dsh-deepseek-llm-api-extensions"] + cfg --> plugin_dsh_base_deepseek_llm_api_extensions plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] cfg --> plugin_dsh_base_session + plugin_dsh_base_session_log_deepseek["session-log-deepseek
@deepseek-ai/dsh-session-log-deepseek"] + cfg --> plugin_dsh_base_session_log_deepseek plugin_dsh_base_typert["typert
@deepseek-ai/dsh-typert-registry"] cfg --> plugin_dsh_base_typert plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"] @@ -30,6 +34,8 @@ flowchart LR cfg --> plugin_dsh_base_user_questions plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent + plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] + cfg --> plugin_dsh_base_plugin_package_inventory_deepseek plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] @@ -179,7 +185,9 @@ flowchart LR | `timer` | `@deepseek-ai/cordis-plugin-timer` | | `hmr` | `@deepseek-ai/cordis-plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | +| `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | | `session` | `@deepseek-ai/dsh-session` | +| `session-log-deepseek` | `@deepseek-ai/dsh-session-log-deepseek` | | `typert` | `@deepseek-ai/dsh-typert-registry` | | `typert-loader` | `@deepseek-ai/dsh-typert-loader` | | `typert-gateway` | `@deepseek-ai/dsh-api-gateway` | @@ -187,6 +195,7 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | +| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 30e6f5da57..49f7fc84d1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.0-rc.8", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -15,15 +15,20 @@ "dsh": "lib/bin.js" }, "files": [ - "lib/*.js", - "config" + "lib/*.js" ], + "dsh": { + "configTrees": [ + { "mount": "config/agent-presets", "path": "../../packages/preset/agent-presets/presets", "scanRoster": true } + ] + }, "license": "MIT", "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-acp-app": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", @@ -42,6 +47,8 @@ "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", + "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", + "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", @@ -50,6 +57,7 @@ "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-sdk-app": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", @@ -76,6 +84,8 @@ "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", + "@deepseek-ai/dsh-webhook": "workspace:^", + "@deepseek-ai/dsh-webhook-github": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "commander": "^15.0.0", @@ -84,6 +94,7 @@ "node-addon-require-builtin": "^0.1.4" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 117c2c6aac..5ed7b32789 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b -README.zh.md: 8e7508b4b8fcbd39e15538c6ee88733bfa9905f1 +README.md: 0f407afa3b06d144681550d5096bf96c498e6451 +README.zh.md: bf4dc4ca9f49c1801d108234411123de459c0444 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dfddd177a7..0f407afa3b 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -6,17 +6,17 @@ This reference defines the profile, web-alias, plugin-management, and config-dum ## Profile boot -`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web`, `headless`, `sdk`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. ### App arguments The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. -A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. +A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. In a `patchReload: live` profile, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port. Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments. @@ -26,6 +26,8 @@ The shipped apps own these command lines: |---|---| | `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | +| `sdk` | no options; stdio carries the JSON-RPC protocol | +| `acp` | no options; stdio carries Agent Client Protocol | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -36,7 +38,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -78,7 +80,7 @@ The production Web runner needs built package and frontend artifacts (`pnpm run Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers. +All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers. New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 8e7508b4b8..bf4dc4ca9f 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -6,17 +6,17 @@ ## Profile 启动 -`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包则来自 profile 中由 pnpm 管理的 `node_modules`。patch 行中的裸插件 `name` 会从 profile 目录开始,按照 Node 的模块解析规则逐级向父目录查找,直至由 dsh 维护的安装后备目录 `$DSH_HOME/profiles/node_modules`。该目录为 dsh 安装中的应用和组合包所依赖的每个包各维护一个符号链接,并在每次启动时修复这些链接。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web`、`headless`、`sdk` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 ### 应用参数 启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。 -每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在线编辑 `cordis.patch.yml` 时,系统会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。 +每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在 `patchReload: live` profile 中,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。 启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。 @@ -26,6 +26,8 @@ |---|---| | `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | +| `sdk` | 无选项;stdio 携带 JSON-RPC 协议 | +| `acp` | 无选项;stdio 携带 Agent Client Protocol | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -36,7 +38,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 @@ -78,7 +80,7 @@ dsh web --help 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 -所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次启动 profile 时,系统都会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;一次性运行模式通过有界关闭流程退出,该流程会先 dispose 监视器。 +所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose(资源释放)所有实时监视器。 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 9aa44f8b22..321849f2d9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -1,8 +1,6 @@ #!/usr/bin/env node /** - * dsh — command-line entry. Dynamic imports per mode keep unrelated modes out - * of each dispatch path; the adapter prints and exits for - * `--help`/`--version`/a parse error, so only a valid mode reaches the switch. + * Command-line entry for dsh. * @module @deepseek-ai/dsh/bin */ @@ -16,7 +14,6 @@ import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the checked-in manifest resolves with the // same relative hop from either artifact. -/** This app's version, read from its checked-in package.json. */ function readVersion(): string { const manifest = JSON.parse( readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 4a366a9a5d..70741703c4 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -120,7 +120,12 @@ function anchorPathSpec(argument: string, cwd: string): string { export function runPlugin(profile: string, args: readonly string[]): number { const dir = resolveProfileDir(profile) if (!existsSync(join(dir, 'package.json'))) { - initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES) + const template = PROFILE_TEMPLATES[profile] + initProfile( + dir, + template?.bundles ?? DEFAULT_PROFILE_BUNDLES, + template?.patchReload, + ) process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`) } const before = readProfileManifest(NAME, dir) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 19c4abb245..68c66a8e34 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -2,8 +2,8 @@ * Shared profile boot for every `dsh` surface: resolve the profile, stack its * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the - * tree over the profile's empty root config, keep the profile patch layer - * live, and wire fail-loud plus bounded shutdown. + * tree over the profile's empty root config, apply its selected patch-reload + * lifecycle, and wire fail-loud plus bounded shutdown. * * App flags are not the launcher's business: the invocation's inner arguments * are provided to the tree through `ctx.cmdlineArgs`, where any injected app @@ -30,16 +30,36 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' - -/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ -const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) - import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import { provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' const NAME = 'dsh' +/** Launcher-owned readiness signal committed only after boot and host setup succeed. */ +function createAppReady(): { service: AppReady; commit(): void } { + let ready = false + const listeners = new Set<() => void>() + return { + service: { + onReady(listener) { + if (ready) { + listener() + return () => {} + } + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + commit() { + if (ready) return + ready = true + for (const listener of [...listeners]) listener() + listeners.clear() + }, + } +} + /** * The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied * over every profile's own layer. Resolved per call, not at module load: @@ -102,7 +122,7 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** One profile's patch layers (application order) and the row index of its pre-flag composition. */ +/** One profile's patch layers, in application order. */ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ @@ -111,11 +131,6 @@ interface ComposedProfile { homePatches: PatchOptions[] /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ overlays: PatchOptions[] - /** - * id → row of the composed tree (bundles + user layers + overlays), for the - * launcher's own row checks. - */ - rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -137,7 +152,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @returns the profile, its patch layers, and the composed row index. + * @returns the profile and its patch layers. */ function composeProfile( name: string, @@ -152,22 +167,9 @@ function composeProfile( if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] - // The SHIPPED root is the part of the roster only this app can resolve: it - // sits beside this app's own config, in both the source and built layouts. - // The writable root the roster appends is `dsh-agent-presets`' own, so a - // launcher that never reaches this patch still finds a person's presets. - if (rows.has('agent-presets')) { - composedOverlays.push({ - id: 'agent-presets', - config: { - ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], - }, - }) - } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } + return { profile, bundlePatches, homePatches, overlays: composedOverlays } } /** Options for {@link runProfile}. */ @@ -207,6 +209,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { const composed = composeProfile(options.profile, options.patchFiles) const app: { current?: Context } = {} + const appReady = createAppReady() const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -255,27 +258,26 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con provideCmdline(hostCtx, { args: options.args, exit: code => void shutdown.shutdown(code), + ready: appReady.service, }) }) app.current = ctx - // A surface can dispose the whole tree while boot or this post-boot watcher - // setup is still in flight — a signal, or a fast one-shot's appExit. Loader - // presence and fiber state own liveness; the initial check skips a tree - // that already exited, and the catch below re-checks for an exit that - // landed mid-setup. Watching is unconditional: a one-shot surface exits - // through its bounded shutdown, which disposes the watchers before the - // loop drains. - if (!signalShutdown.signal.aborted + // A live-reload profile can dispose the whole tree while post-boot watcher + // setup is in flight — a signal or appExit. Loader presence and fiber state + // own liveness; the initial check skips a tree that already exited, and the + // catch below re-checks for an exit that landed mid-setup. Startup-frozen + // profiles apply every user layer above but install no HMR fallback or watcher. + if (composed.profile.patchReload === 'live' + && !signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) { try { - // Config-only HMR for the live profile patch layer: the web bundle - // disables the shared module-reload `hmr` row (its reload lifecycle is - // untested), so when the composition leaves no HMR service, mount a - // watch-only instance with no module roots — cordis.patch.yml edits stay - // live on every long-lived surface. A silent skip would break the - // documented hot-reload contract. HMR injects the timer service, which a - // bare custom profile may not mount either. + // Config-only HMR for the live profile patch layer: dsh-base disables + // module reload by default, so when no profile explicitly enabled that + // service, mount a watch-only instance with no module roots — + // cordis.patch.yml edits stay live without replacing source modules. A + // silent skip would break the documented reload contract. HMR injects + // the timer service, which a bare custom profile may not mount either. if (ctx.get('hmr') === undefined) { if (ctx.get('timer') === undefined) { await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }) @@ -296,5 +298,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con suppressShutdownError(ctx, signalShutdown.signal, error) } } + if (!signalShutdown.signal.aborted + && ctx.fiber.state === FiberState.ACTIVE + && ctx.get('loader') !== undefined) { + appReady.commit() + } return { ctx, shutdown } } diff --git a/apps/cli/src/sdk-source.cordis.patch.yml b/apps/cli/src/sdk-source.cordis.patch.yml new file mode 100644 index 0000000000..8545c2fd3a --- /dev/null +++ b/apps/cli/src/sdk-source.cordis.patch.yml @@ -0,0 +1,5 @@ +# Clean source checkouts have no build-generated Typert contributor modules. +# The SDK JSON-RPC application does not consume the Typert remote gateway; +# installed builds retain the complete dsh-base row. +- id: typert-loader + disabled: true diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 75ab640fcc..2fffff6700 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,7 +1,16 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { Readable, Writable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' +import { + client as createAcpClientApp, + methods, + ndJsonStream, + PROTOCOL_VERSION, + type SessionNotification, +} from '@agentclientprotocol/sdk' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { execa } from 'execa' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -356,6 +365,22 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(headlessHelp.stderr).toBe('') expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(sdkHelp.code).toBe(0) + expect(sdkHelp.stderr).toBe('') + expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk') + + const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(acpHelp.code).toBe(0) + expect(acpHelp.stderr).toBe('') + expect(acpHelp.stdout).toContain('Usage: dsh --profile acp') + const missingTask = await runBuiltBin(['--profile', 'headless'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', @@ -367,6 +392,169 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('reports SDK startup failure when stdin reaches EOF first', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) + const patch = join(home, 'broken-sdk.cordis.yml') + writeFileSync(patch, [ + '- insert:', + ' - id: missing-sdk-startup-plugin', + ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"', + '', + ].join('\n')) + try { + const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call', + }, home) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('plugin tree failed to load') + expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) + const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { + cwd: home, + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { + ...process.env, + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'built-sdk-profile-no-call', + }, + extendEnv: false, + }) + const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]() + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + const response = async (id: number): Promise> => { + for (;;) { + const line = await stdoutLines.next() + if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`) + let value: Record + try { + value = JSON.parse(line.value) as Record + } catch { + throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`) + } + if (value.id === id) return value + } + } + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })}\n`) + expect(await response(1)).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, + }) + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`) + expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} }) + const result = await child + expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0) + expect(stderr).toBe('') + } finally { + child.kill('SIGKILL') + await child + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => { + const apiKey = 'built-acp-profile-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'ACP BUILT PROFILE OK', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-')) + const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { + cwd: home, + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { + ...process.env, + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + DSH_PERMISSION_MODE: 'danger-full-access', + }, + extendEnv: false, + }) + const rawOut: string[] = [] + const passthrough = new Readable({ read() {} }) + child.stdout.on('data', (chunk: Buffer) => { + rawOut.push(chunk.toString('utf8')) + passthrough.push(chunk) + }) + child.stdout.on('end', () => { passthrough.push(null) }) + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const updates: SessionNotification['update'][] = [] + const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' }) + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params.update) + return Promise.resolve() + }) + .onRequest(methods.client.session.requestPermission, () => { + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }) + const client = clientApp.connect(stream).agent + try { + const initialized = await client.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }) + expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' }) + expect(initialized.agentCapabilities).toEqual({ + mcpCapabilities: { http: true }, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, + }) + expect('_meta' in initialized).toBe(false) + const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] }) + expect(session.sessionId).toBeTruthy() + expect(await client.request(methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'reply from the built ACP profile' }], + })).toEqual({ stopReason: 'end_turn' }) + expect(updates).toContainEqual(expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'ACP BUILT PROFILE OK' }, + })) + const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk') + expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true) + expect(server.requests).toHaveLength(1) + child.stdin.end() + const result = await child + expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0) + expect(result.stderr).toBe('') + for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) { + expect(() => JSON.parse(line) as unknown).not.toThrow() + } + } finally { + child.kill('SIGKILL') + await child + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + it('runs the headless profile through its app-owned task positional', async () => { const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts new file mode 100644 index 0000000000..93421ea93a --- /dev/null +++ b/apps/cli/tests/github-webhook-real.e2e.ts @@ -0,0 +1,442 @@ +/** Real CLI and DeepSeek evidence for a GitHub webhook-created Session. */ + +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import { createHmac, randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' +import { createServer } from 'node:net' +import type { AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js') +const OVERLAY = fileURLToPath(new URL( + '../../../examples/web-github-review/tests/fixtures/real-cli/cordis.yml', + import.meta.url, +)) +const SECRET = 'github-webhook-real-e2e-secret' +const DELIVERY = 'github-webhook-real-e2e-delivery' +const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK' +const TITLE = 'GitHub webhook real e2e' + +interface SessionList { + items: Array<{ + sessionId: string + cwd?: string + agentPreset?: string + blank: boolean + }> +} + +interface WorkspaceBaseline { + items: Array<{ + path: string + sessionIds: string[] + }> +} + +interface HistoryPage { + events: Array<{ + event: { + type: string + data: unknown + } + }> + hasMore: boolean +} + +interface ProcessObservation { + readonly ready: Promise + readonly text: () => string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Capture bounded process output and resolve the public Web URL after settled boot. */ +function observeProcess(child: ChildProcess): ProcessObservation { + let output = '' + let settled = false + let resolveReady!: (url: string) => void + let rejectReady!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const timer = setTimeout(() => { + if (!settled) rejectReady(new Error(`dsh web did not become ready within 90s:\n${output}`)) + }, 90_000) + timer.unref() + const append = (chunk: Buffer | string): void => { + output = `${output}${String(chunk)}`.slice(-100_000) + const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output) + if (settled || match?.[1] === undefined) return + settled = true + clearTimeout(timer) + resolveReady(match[1].replace('0.0.0.0', '127.0.0.1')) + } + child.stdout?.on('data', append) + child.stderr?.on('data', append) + child.once('error', (error) => { + if (!settled) rejectReady(error) + }) + child.once('exit', (code) => { + if (!settled) rejectReady(new Error(`dsh web exited before readiness (code ${String(code)}):\n${output}`)) + }) + return { ready, text: () => output } +} + +/** Reserve and release one loopback port for the isolated webhook listener. */ +async function freePort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const port = (server.address() as AddressInfo).port + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + return port +} + +/** Invoke one public Remote method over its HTTP carrier. */ +async function remoteRpc(baseUrl: string, endpoint: string, args: object): Promise { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`, + method: endpoint, + payload: { args }, + }), + }) + if (!response.ok) { + throw new Error(`${endpoint} returned HTTP ${String(response.status)}: ${await response.text()}`) + } + const envelope = await response.json() as { + result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + } + if (!envelope.result.ok) { + throw new Error(`${endpoint} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`) + } + return envelope.result.value +} + +/** Read one opening item from a public Remote stream. */ +async function openingStreamItem( + baseUrl: string, + endpoint: string, + args: object, + accepts: (value: unknown) => boolean, +): Promise> { + const socket = new WebSocket(`${baseUrl.replace(/^http/u, 'ws')}/api/remote.mux`) + const streamId = `github-webhook-real-${endpoint}-${randomUUID()}` + try { + await new Promise((resolve, reject) => { + const cleanup = (): void => { + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const opened = (): void => { + cleanup() + resolve() + } + const failed = (): void => { + cleanup() + reject(new Error(`${endpoint} carrier failed before opening`)) + } + const closed = (): void => { + cleanup() + reject(new Error(`${endpoint} carrier closed before opening`)) + } + socket.addEventListener('open', opened) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + }) + return await new Promise>((resolve, reject) => { + const timer = setTimeout(() => { finish(new Error(`${endpoint} did not publish its opening item`)) }, 10_000) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeEventListener('message', message) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', closed) + } + const finish = (error: Error | undefined, value?: Record): void => { + cleanup() + if (error !== undefined) reject(error) + else if (value === undefined) reject(new Error(`${endpoint} opening item was absent`)) + else resolve(value) + } + const message = (event: MessageEvent): void => { + try { + if (typeof event.data !== 'string') throw new Error(`${endpoint} published a non-text frame`) + const frame: unknown = JSON.parse(event.data) + if (!isRecord(frame) || frame.streamId !== streamId) return + if (frame.type === 'error') { + finish(new Error(`${endpoint} failed: ${JSON.stringify(frame.error)}`)) + return + } + if (frame.type === 'end') { + finish(new Error(`${endpoint} ended before its opening item`)) + return + } + if (frame.type === 'item' && isRecord(frame.value) && accepts(frame.value)) { + finish(undefined, frame.value) + } + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + } + } + const failed = (): void => { finish(new Error(`${endpoint} carrier failed before its opening item`)) } + const closed = (): void => { finish(new Error(`${endpoint} carrier closed before its opening item`)) } + socket.addEventListener('message', message) + socket.addEventListener('error', failed) + socket.addEventListener('close', closed) + socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } })) + }) + } finally { + socket.close() + } +} + +/** Read the current Workspace baseline from a fresh follow generation. */ +async function workspaceBaseline(baseUrl: string): Promise { + const frame = await openingStreamItem( + baseUrl, + 'workspace/follow', + {}, + value => isRecord(value) && value.type === 'baseline' && isRecord(value.value), + ) + return frame.value as WorkspaceBaseline +} + +/** Read the explicit page cut from a fresh Session follow generation. */ +async function sessionCursor(baseUrl: string, sessionId: string): Promise { + const frame = await openingStreamItem( + baseUrl, + 'session/follow', + { request: { address: { kind: 'session', sessionId } } }, + value => isRecord(value) && value.type === 'opened' && Number.isSafeInteger(value.cursor), + ) + return frame.cursor as number +} + +/** Read Session history at the cursor explicitly opened for this page. */ +async function history(baseUrl: string, sessionId: string): Promise { + const throughSeq = await sessionCursor(baseUrl, sessionId) + return remoteRpc(baseUrl, 'session/page', { + request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 100 }, + }) +} + +/** Poll a public observation until it satisfies the test's behavior predicate. */ +async function eventually( + child: ChildProcess, + processOutput: () => string, + label: string, + probe: () => Promise, + accepts: (value: T) => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + let lastValue: T | undefined + let lastError: unknown + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`dsh web exited while waiting for ${label} (code ${String(child.exitCode)}):\n${processOutput()}`) + } + try { + lastValue = await probe() + if (accepts(lastValue)) return lastValue + } catch (error) { + lastError = error + } + await delay(300) + } + throw new Error( + `timed out waiting for ${label}; last value=${JSON.stringify(lastValue)}; ` + + `last error=${String(lastError)}; process output:\n${processOutput()}`, + ) +} + +/** Return every text block from durable assistant messages. */ +function assistantText(page: HistoryPage): string { + const text: string[] = [] + for (const { event } of page.events) { + if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) continue + const content = event.data.message.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') text.push(block.text) + } + } + return text.join('\n') +} + +/** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */ +async function stop(child: ChildProcess): Promise { + if (child.exitCode !== null) return + let resolveClosed!: () => void + const closed = new Promise((resolve) => { resolveClosed = resolve }) + child.once('close', resolveClosed) + child.kill('SIGTERM') + if (await Promise.race([closed.then(() => true), delay(10_000, false, { ref: false })])) return + if (child.exitCode === null) child.kill('SIGKILL') + await Promise.race([closed, delay(5_000, undefined, { ref: false })]) +} + +/** Send the sole synthetic external interaction: one signed GitHub delivery. */ +async function sendGitHubDelivery(origin: string): Promise { + const body = JSON.stringify({ + action: 'ready_for_review', + number: 4242, + repository: { full_name: 'deepseek-harness/deepseek-harness' }, + pull_request: { + title: 'Real CLI webhook e2e', + html_url: 'https://github.com/deepseek-harness/deepseek-harness/pull/4242', + draft: false, + user: { login: 'octocat' }, + base: { ref: 'master', sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + head: { ref: 'webhook-e2e', sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + }, + }) + const signature = `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}` + return await fetch(`${origin}/github`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-delivery': DELIVERY, + 'x-github-event': 'pull_request', + 'x-hub-signature-256': signature, + }, + body, + }) +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real dsh CLI and model', () => { + it('creates, attaches, prompts, and completes a Workspace Session', async () => { + expect(existsSync(BUILT_BIN), `missing built CLI ${BUILT_BIN}; run pnpm run build:official`).toBe(true) + const root = await mkdtemp(join(tmpdir(), 'dsh-github-webhook-real-')) + const workspacePath = join(root, 'workspace') + await mkdir(workspacePath) + const canonicalWorkspacePath = await realpath(workspacePath) + const webhookPort = await freePort() + const child = spawn(process.execPath, [ + BUILT_BIN, + 'web', + '--patch', OVERLAY, + '--no-open', + '--host', '127.0.0.1', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_GITHUB_E2E_MARKER: MARKER, + DSH_GITHUB_E2E_WORKSPACE: workspacePath, + DSH_GITHUB_WEBHOOK_PORT: String(webhookPort), + DSH_GITHUB_WEBHOOK_SECRET: SECRET, + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const observation = observeProcess(child) + + try { + const baseUrl = await observation.ready + const webhookOrigin = `http://127.0.0.1:${String(webhookPort)}` + + expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404) + expect((await sendGitHubDelivery(baseUrl)).status).not.toBe(202) + expect((await sendGitHubDelivery(webhookOrigin)).status).toBe(202) + + const workspaces = await eventually( + child, + observation.text, + 'one Workspace-attached Session', + async () => await workspaceBaseline(baseUrl), + value => value.items.some(workspace => + workspace.path === canonicalWorkspacePath && workspace.sessionIds.length === 1), + 30_000, + ) + const workspace = workspaces.items.find(item => item.path === canonicalWorkspacePath) + const sessionId = workspace?.sessionIds[0] + if (sessionId === undefined) throw new Error('workspace/follow did not expose the webhook Session') + + const sessions = await remoteRpc(baseUrl, 'session/list', { _request: {} }) + expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({ + agentPreset: 'minimal', + blank: false, + cwd: canonicalWorkspacePath, + }) + + const admitted = await eventually( + child, + observation.text, + 'webhook provenance, title, and permission events', + async () => await history(baseUrl, sessionId), + (page) => { + const events = page.events.map(item => item.event) + const title = events.find(event => event.type === 'session/title') + const permission = events.find(event => + event.type === 'permission/preset' + && isRecord(event.data) + && event.data.preset === 'read-only') + const message = events.find(event => + event.type === 'user/message' + && isRecord(event.data) + && isRecord(event.data.source) + && event.data.source.kind === 'webhook') + return isRecord(title?.data) && title.data.title === TITLE + && permission !== undefined + && isRecord(message?.data) && isRecord(message.data.source) + && message.data.source.provider === 'github' + && message.data.source.deliveryId === DELIVERY + }, + 30_000, + ) + const webhookMessage = admitted.events.map(item => item.event) + .find(event => event.type === 'user/message' + && isRecord(event.data) + && isRecord(event.data.source) + && event.data.source.kind === 'webhook') + expect(webhookMessage?.data).toMatchObject({ + content: [{ type: 'text', text: `Reply with exactly ${MARKER} and no other text. Do not call tools.` }], + source: { + kind: 'webhook', + provider: 'github', + deliveryId: DELIVERY, + ruleId: 'github-real-e2e', + source: 'github-real-e2e', + }, + }) + + const completed = await eventually( + child, + observation.text, + 'a real DeepSeek assistant response', + async () => await history(baseUrl, sessionId), + page => assistantText(page).includes(MARKER), + 150_000, + ) + expect(assistantText(completed)).toContain(MARKER) + } finally { + await stop(child) + await rm(root, { recursive: true, force: true }) + } + }, 330_000) +}) diff --git a/apps/cli/tests/profile-hmr.spec.ts b/apps/cli/tests/profile-hmr.spec.ts new file mode 100644 index 0000000000..6fed867d92 --- /dev/null +++ b/apps/cli/tests/profile-hmr.spec.ts @@ -0,0 +1,42 @@ +/** Module-HMR ownership across the real shipped profile bundle layers. */ + +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' + +const REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url)) + +/** Load one shipped bundle patch through the same parser as profile boot. */ +function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'web-app'): PatchOptions[] { + return loadOverlayPatches('profile-hmr test', join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml')) +} + +/** Resolve the effective HMR row after the supplied layers. */ +function hmr(layers: PatchOptions[][]) { + const row = composeEntries(layers).find(entry => entry.id === 'hmr') + if (row === undefined) throw new Error('the base bundle must insert the hmr row') + return row +} + +describe('profile module-HMR policy', () => { + it.each(['web-app', 'headless', 'sdk-app', 'acp-app'] as const)( + '%s inherits the disabled base row without a mode override', + (mode) => { + const modePatches = bundle(mode) + expect(modePatches.some(patch => patch.id === 'hmr')).toBe(false) + expect(hmr([bundle('base'), modePatches])).toMatchObject({ + disabled: true, + config: { root: ['.'] }, + }) + }, + ) + + it('requires an explicit later layer to enable source-module reload', () => { + expect(hmr([bundle('base'), [{ id: 'hmr', disabled: false }]])).toMatchObject({ + disabled: false, + config: { root: ['.'] }, + }) + }) +}) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 0e98af0477..1dc66b2f08 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' +import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -21,7 +21,6 @@ import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-token-meter' -const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') @@ -56,8 +55,8 @@ async function bootWeb( // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's - // outcome. Point it at a temp file for the same reason the roster below - // names only the shipped root. + // outcome. Point it at a temp file for the same reason the roster row + // below pins `includeUserRoot` off. { id: 'settings', config: { path: settingsFile, watch: false } }, // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this // file writes the developer's own `~/.dsh/storages/` — and then reads it @@ -95,18 +94,12 @@ async function bootWeb( { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' }, ] }, - // The roster AppCLIEntry would patch in; only the shipped root, so a - // developer's own `~/.dsh/.preset` cannot change this test's outcome. + // Pin the roster away from the developer's machine: `includeUserRoot` + // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. - { - id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: false, - }, - }, + // document overrides. No `roots` entry: the plugin bundles the shipped + // presets itself and prepends their root. + { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, ...extra, ] // The surface is patch layers over an empty preset root, so the root sits @@ -241,6 +234,7 @@ describe('the shipped Web composition', () => { 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) + expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() } finally { await handle.dispose() } @@ -260,6 +254,7 @@ describe('the shipped Web composition', () => { expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) .toContain('Absolute path') + expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined() expect(handle.agent.ctx.get('compaction')).toBeUndefined() } finally { @@ -305,6 +300,7 @@ describe('the shipped Web composition', () => { // And it keeps the standard agent's own tools rather than replacing them. expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) expect(tools).not.toContain('str_replace_editor') + expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. @@ -332,6 +328,7 @@ describe('the shipped Web composition', () => { const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor') + expect(ctx.commands.find(coded.agent, 'goal')).toBeDefined() const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' expect(sdk).not.toContain('str_replace_editor') expect(sdk).toContain('web_search') @@ -365,7 +362,7 @@ describe('the shipped Web composition', () => { // The preset's skill root is derived from its own `baseUrl`, so the skill // travels with the directory wherever the preset is installed. const skill = join( - CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', ) expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) @@ -437,7 +434,7 @@ describe('the shipped Web composition', () => { // agent down disposes its whole subtree. Inherited, that rewrote the // shipped composition — truncating it to `[]` the first time a session // ended — so `PresetTree` refuses to write at all. - const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml') + const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml') const before = await readFile(path, 'utf8') const handle = await ctx.agents.create({ @@ -465,7 +462,7 @@ describe('product Bundle and user-preset intersection', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) const userRoot = join(root, 'presets') const settingsFile = join(root, 'settings.yaml') - const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8') await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard @@ -492,10 +489,8 @@ describe('product Bundle and user-preset intersection', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is the plugin's own, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }, @@ -598,8 +593,10 @@ describe('a switch survives the session', () => { }) try { // The api-proxy's select does exactly this pair while the session is blank. + expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal') handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' }) + expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // The header keeps the creation fact; the log carries what it runs. expect(handle.agent.session.header.agentPreset).toBe('standard') @@ -730,15 +727,11 @@ describe('a launcher that configures no writable root', () => { ) const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') await writeFile(settingsFile, '{}\n') - // Only the shipped root, exactly what `composeProfile` supplies; the + // No configured roots: the shipped one is the plugin's own, and the // writable one is the roster's own default rather than this patch's job. derivedCtx = await bootWeb(settingsFile, [{ id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], - includeUserRoot: true, - }, + config: { default: 'standard', includeUserRoot: true }, }]) }, 120_000) @@ -781,12 +774,10 @@ describe('authoring a preset on the shipped composition', () => { id: 'agent-presets', config: { default: 'standard', - roots: [ - { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, - // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. - { path: userRoot, trust: 'user' }, - ], + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. The shipped root is the + // plugin's own, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false, }, }]) @@ -893,3 +884,64 @@ describe('a session keeps the preset it was created with', () => { } }) }) + +describe('a composition that configures its own preset roots', () => { + let rootsCtx: Context + let teamRoot: string + + beforeAll(async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-')) + const settingsFile = join(home, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + // A workspace-shared root beside the deployment: one preset of its own, + // plus a directory that claims a shipped id. + teamRoot = join(home, 'team-presets') + const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8') + for (const id of ['team-spec', 'minimal']) { + await mkdir(join(teamRoot, id), { recursive: true }) + await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) + } + // The user layer of the reported regression: a profile's cordis.patch.yml + // configuring a shared preset root. The plugin must EXTEND it with its + // own shipped root, never lose it. + rootsCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [{ path: teamRoot, trust: 'user' }], + includeUserRoot: false, + }, + }]) + }, 120_000) + + afterAll(async () => { + await rootsCtx.fiber.dispose() + }) + + it('keeps configured roots alongside the always-prepended shipped root', async () => { + expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ + SHIPPED_PRESET_ROOT, + teamRoot, + ]) + + const listed = await rootsCtx.agentPresets.list() + expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec']) + expect(listed.every(preset => preset.broken === undefined)).toBe(true) + // The shipped root comes first: a configured directory claiming a shipped + // id is shadowed, never the other way around. + expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system') + expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user') + }) + + it('composes an agent from a configured-root preset', async () => { + const handle = await rootsCtx.agents.create({ + sessionId: SessionId('preset-team-spec'), + setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), + }) + try { + expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) +}) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index ce37022cdf..f94ce582a6 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -13,11 +13,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' /** @@ -101,7 +102,7 @@ describe('the shipped shell composition (real bundle layers)', () => { }) describe('shipped agent presets gate both shell tools by platform', () => { - const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets') + const presetRoot = SHIPPED_PRESET_ROOT it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { const entries: unknown = yaml.load( diff --git a/apps/web/package.json b/apps/web/package.json index 581ba55672..d1f70bedfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.0-rc.8", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -17,32 +17,40 @@ }, "files": [ "dist", - "!dist/**/*.map" + "!dist/**/*.map", + "!dist/preview.html", + "!dist/preview" ], "scripts": { "build": "vite build", "dev": "vite", - "watch": "vite build --watch --no-emptyOutDir" + "watch": "vite build --watch --no-emptyOutDir", + "build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz", + "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1" }, "license": "MIT", "devDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", + "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@vitejs/plugin-react": "^4.0.0", + "http-server": "^14.1.1", + "fflate": "^0.8.2", "playwright": "^1.49.0", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8", - "fflate": "^0.8.2" + "vitest": "^4.1.8" } } diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 7359ce5ade..2ff361f3db 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,8 +1,4 @@ -/** - * Web application entry: thin bootstrap over the shell library. Everything — - * module-table seeding, the boot page, and the UI-renderer handoff — lives - * in @deepseek-ai/dsh-client-web; this file only finds the mount point. - */ +/** Browser entry for the Web client. */ import { AppWebEntry } from '@deepseek-ai/dsh-client-web' const el = document.getElementById('root') diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts index 0a9b04ea5f..15e2c1c8da 100644 --- a/apps/web/src/node-module-stub.ts +++ b/apps/web/src/node-module-stub.ts @@ -3,10 +3,10 @@ * configured loader path and fails loud if that assumption changes. */ -/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ +/** Fail if browser boot reaches Node's module loader. */ export const createRequire = (): never => { throw new Error('node:module is not available in the browser') } -/** Erased type peer for the vendored loader's type-only LoadHookContext import. */ +/** Type-only peer for the vendored loader. */ export type LoadHookContext = never diff --git a/apps/web/src/preview.ts b/apps/web/src/preview.ts new file mode 100644 index 0000000000..f3d51f8bbc --- /dev/null +++ b/apps/web/src/preview.ts @@ -0,0 +1,14 @@ +/** + * Worker-preview bootstrap: the one module preview.html adds ahead of the + * stock entry tag. The runtime's optional source stage owns the pre-Cordis + * chooser; the unchanged Host connector then owns the Worker handshake. + * Everything after those calls is the served startup chain verbatim. + */ +import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker' +import { + chooseWorkerHostSource, connectWorkerHost, IMAGE_FILE_NAME, +} from '@deepseek-ai/dsh-experimental-webworker-runtime/client' + +const image = `preview/${IMAGE_FILE_NAME}` +const source = await chooseWorkerHostSource({ image }) +await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image, overlays: source.overlays }) diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 5daf55b021..3c260eab76 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/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 apps/web/tests/README.md -README.md: acb0c300bafe221f6a92f0168908bebf965377b9 -README.zh.md: 029f3190bb89bede5506d3d58f5e6df229218493 +README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d +README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index acb0c300ba..2104d9422c 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -33,14 +33,11 @@ then surfaces as a missed selector or a stale mirrored value — a loud failure, never a silent pass. `scaffold.ts` follows this rule for the welcome-notice namespace, acknowledgement field, version, and asserted Chinese copy. -Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so +One kind of Client import stands. `assembled-boot.ts` drives the shell itself, so it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what -that harness is for, and both packages are already in the Host graph. Separately, -the chat scenarios import `conversationContextKey` from -`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable -through the unsplit `directory-picker` packages and pulls nothing further in. -That reachability is incidental, not a guarantee — if it ever leaves the graph, -mirror the helper like the rest. +that harness is for, and both packages are already in the Host graph. The chat +scenarios mirror `conversationContextKey` in `support.ts` instead of importing +its Client owner. Nothing mechanically enforces this rule; keep it in review. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 029f3190bb..4dfa5b2f61 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -26,12 +26,10 @@ Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默 通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。 -有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 +有一类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 `@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 `@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该 -harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从 -`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为 -`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是 -偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。 +harness 的用途,且这两个包本来就在 Host 图中。chat 场景则在 `support.ts` 中镜像 +`conversationContextKey`,而不 import 其 Client owner。 没有任何机制强制这条规则;靠 review 守住它。 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 414aacfb60..a173f8399e 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -50,13 +50,13 @@ describe('web e2e: Full access confirmation', () => { const access = page.locator('button[aria-label^="访问模式"]').first() await access.waitFor({ timeout: 10_000 }) - expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区') + expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write') await access.click() - await page.getByRole('menuitem', { name: '完全权限' }).click() - const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' }) + await page.getByRole('menuitem', { name: 'Full access' }).click() + const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' }) await dialog.waitFor({ timeout: 10_000 }) - const enable = dialog.getByRole('button', { name: '启用完全权限' }) + const enable = dialog.getByRole('button', { name: '启用 Full access' }) expect(await enable.isDisabled()).toBe(true) // The modal is in this page's body (not a native/new window) and escapes @@ -69,7 +69,7 @@ describe('web e2e: Full access confirmation', () => { expect(await enable.isEnabled()).toBe(true) await enable.click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) - .toBe('访问模式,当前:完全权限') + .toBe('访问模式,当前:Full access') expect(await dialog.count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 1a27f96c6e..e2b9134ca3 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -27,8 +27,8 @@ const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +/** The shipped roster, bundled inside the `dsh-agent-presets` package. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url)) const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() @@ -60,10 +60,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, agentPresets: { - roots: [ - { path: SHIPPED_PRESETS, trust: 'system' }, - { path: userRoot, trust: 'user' }, - ], + // The shipped root is the plugin's own, prepended before this. + roots: [{ path: userRoot, trust: 'user' }], default: 'standard', }, }) @@ -92,8 +90,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) - // The intro carries the guidance a create button used to imply, and the - // shipped rows offer view/copy but never delete or a location — their + // The intro states the copy path directly, and the shipped rows offer + // view/copy but never delete or a location — their // install is overwritten by upgrades and is not the user's to manage. expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建') expect(snapshot).not.toContain('新建预设') @@ -259,17 +257,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.waitFor({ state: 'detached', timeout: 10_000 }) await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + const response = await fetch(`${scaffold.baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { - result: { value?: { sessions: unknown[] } } + result: { value?: { items: unknown[] } } } - return JSON.stringify(body.result.value?.sessions ?? body.result) + return JSON.stringify(body.result.value?.items ?? body.result) }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') }, 60_000) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6095188534..137c00aee8 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -1,7 +1,5 @@ -// Web e2e scenario: agent-preset selection. The roster's `roots` is an -// assembly fact the CLI entry resolves and patches in, so every other lane -// boots with an empty roster and no preset surface at all; this is the one -// lane that mounts the SHIPPED presets and puts them in front of a browser. +// Web e2e scenario: agent-preset selection. Every lane mounts the plugin's +// own shipped presets; this is the lane that puts them in front of a browser. // // Two surfaces, one host rule: a session's composition is fixed when the // session starts. Before that, the new-session chip stages the choice beside @@ -30,8 +28,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') -/** The shipped roster, beside the composition that names it. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' /** A project skill only a preset that mounts `skill-filesystem` can discover. */ @@ -145,11 +141,12 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise * @returns the live session's preset, or undefined before it is listed. */ async function livePreset(baseUrl: string): Promise { - const response = await fetch(`${baseUrl}/api/session.list`, { + const response = await fetch(`${baseUrl}/api/session/list`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + type: 'client-request', rpcId: 'agent-preset-live', method: 'session/list', + payload: { args: { _request: {} } }, }), }) const body = await response.json() as { @@ -172,9 +169,9 @@ describe('web e2e: agent-preset selection', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ - agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' }, - }) + // The scaffold's default roster pin is exactly this scenario's shape: the + // plugin's shipped presets, default `standard`. + scaffold = await launchWebScaffold({}) // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. @@ -246,9 +243,9 @@ describe('web e2e: agent-preset selection', () => { const onMinimal = await menuOptions(page) expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) - // The host-plane commands and the client's own contribution are the - // floor: they belong to no preset and never move. - expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true) + // Preset-scoped commands follow the switch; the client's own model command + // remains outside every preset. + expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false) expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) await composer.fill('') @@ -265,6 +262,7 @@ describe('web e2e: agent-preset selection', () => { .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) const onStandard = await menuOptions(page) expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) + expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) await composer.fill('') }, 90_000) diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index e57367ee46..9739c13b07 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -1,16 +1,5 @@ -// Web e2e scenario: the composer-takeover approval panel under a long -// command. The shipped composition confines bash through the sandbox policy -// and routes its escalation through the approval seam, so a read-only session -// asked to write a file produces a REAL pending approval — the panel renders -// in the browser, the test measures its geometry, answers through it, and the -// escalated command then runs. Replay is deterministic: the denial, the -// escalation retry and its command text arrive from replayed chunks, and the -// answer click is the test's own gesture (the same sanctioned reaction to -// model content as the question composer: the turn cannot complete without it). -// -// Geometry is the point of the scenario. The command is unbounded model text, -// and an uncapped card grows with it until the refuse/allow buttons leave the -// viewport — an approval the user could see and not answer. +// Browser geometry for a pending approval whose model-supplied command would +// push the actions outside the viewport without a capped text region. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -29,17 +18,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// The scenario's one golden: the waiting panel. Everything the answered state -// proves is asserted directly — see the world-state block at the end. +// The golden covers the stable waiting panel; direct assertions cover its answer. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() -// Irreducible payload: the command has to be long enough to pass the card's -// height cap, which is the only command length that reproduces an action row pushed off -// screen. Unrelated tokens, not a repeated word — a repeated word is what the -// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a -// short command proves nothing here. The formula keeps the source small; the -// model receives the expanded literal it has to put in the command. +// Unrelated tokens keep the recorded model from compressing the payload into a +// short shell loop that would not overflow the card. const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ') const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.` @@ -77,19 +61,12 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) - // The composer's own text cap, measured on the live draft scrollport before - // the takeover replaces it — the box that carries the cap, while the - // textarea inside it is as tall as the whole draft. The panel's scroll - // region must stop at the same height (the designer's requirement: one cap - // for the composer seat), and measuring it here keeps the assertion free of - // the px value itself. + // Derive the expected cap from the live composer instead of duplicating its pixel value. await input.fill(CAP_PROBE) const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0) expect(composerCap).toBeGreaterThan(0) await input.fill('') - // Read-only: the mode whose denial the model escalates from. Switched - // through the shipped access-mode chip, not a test-only override. await page.locator('[aria-label^="Access mode"]').click() await page.getByRole('menuitem', { name: 'Read Only' }).click() await expect.poll( @@ -101,22 +78,15 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { await input.fill(PROMPT) await input.press('Enter') - // The panel takes over the input area while the tool blocks. Its presence - // is a STABLE waiting state (it stays until answered), so waitFor is - // race-free. const panel = page.locator('[data-approval-key]') await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 }) const scroll = panel.locator('[data-approval-scroll]') await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0) if (MODE !== 'record') { - // This golden owns the stable waiting surface; the answered golden below - // owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - // The uncapped-card hazard the header names, measured at the lane - // baseline and at a short viewport, on the live panel. const original = page.viewportSize() ?? { width: 1680, height: 1000 } for (const height of [1000, 700]) { await page.setViewportSize({ width: 900, height }) @@ -140,11 +110,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { }) expect(geometry.buttons).toBe(2) expect(geometry.scrolls).toBe(true) - // One cap for the seat: the panel's text region stops where the - // composer draft does (sub-pixel tolerance for the shared padding). + // The panel and composer share one cap; allow sub-pixel layout variance. expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1) - // Both buttons stay inside the card AND inside the viewport — the - // answerable state the cap exists to guarantee. expect(geometry.actionsTop).toBeGreaterThan(0) expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport) expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom) @@ -159,12 +126,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { await recordFixture(scaffold, sessionId, FIXTURE) return } - // World state: the granted escalation is what let the command run, and the - // panel leaves with the regular composer restored. Asserted on the world - // and the DOM rather than through a transcript golden — the denied first - // attempt renders the OS's own refusal ("Operation not permitted" on - // macOS, "Read-only file system" on Linux), so the answered transcript is - // not a platform-neutral golden surface. + // The denied attempt contains platform-specific OS text, so direct state + // and DOM assertions cover the answered outcome. expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1))) .toContain('allowed-once') const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8') diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 981836775c..10264b7103 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -22,6 +22,11 @@ interface AssembledPlugin extends WebBootEntry { bundlePath: string } +interface AssembledBootOptions { + /** Package ids omitted from this mounted composition. */ + readonly exclude?: readonly string[] +} + interface ClientPackageManifest { name?: string exports?: Record @@ -187,24 +192,25 @@ export function installAssembledBootEnv(): void { * Mount the assembled application on the fixture transport; the teardown * registered by installAssembledBootEnv disposes it. * @param search - fixture query string used to select deterministic host behavior. + * @param options - composition changes applied to this mount. */ -export function mountAssembledApp(search = '?fixture'): void { +export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void { + const excluded = new Set(options.exclude) + const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id)) history.replaceState(null, '', `/${search}`) const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) - win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } + win.__DSH_BOOT__ = { rev: 'fx', entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) } const [facadeRow] = bootInjections(win.__DSH_BOOT__) if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row') ;(0, eval)(facadeRow.text) - // Mirror the blocking Host-injected scripts before the Vite entry calls create(). - for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) { - const plugin = PLUGINS.find(candidate => candidate.id === id) - if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`) - const code = bundles.get(plugin.url) - if (code === undefined) throw new Error(`missing built bundle ${plugin.url}`) - ;(0, eval)(code) - } + // Mirror the blocking Host-injected modules script before the Vite entry calls create(). + const modules = plugins.find(candidate => candidate.id === '@deepseek-ai/dsh-client-modules') + if (modules === undefined) throw new Error('missing parser-preloaded fixture row @deepseek-ai/dsh-client-modules') + const modulesCode = bundles.get(modules.url) + if (modulesCode === undefined) throw new Error(`missing built bundle ${modules.url}`) + ;(0, eval)(modulesCode) act(() => { const entry = new AppWebEntry(root, { loadBundle: async (url) => { diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 0e0a98de93..b0fdc62ddb 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,8 +1,5 @@ -// Web e2e scenario: the session-header background-job list over the real -// host. No model call is involved — a genuine `run_in_background` bash call -// registers with `ctx.jobs`, and the assertion chain is the whole delivery -// path: registry change feed → api-proxy `session/jobs` frame → the client's -// `jobsBySession` mirror → the header action. +// Session-header background jobs driven by a real `ctx.jobs` entry. No model +// call is involved. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -30,7 +27,7 @@ const SEED_ID = 'background-job-list-web-e2e' const COMMAND = 'sleep 45' /** - * Wait for the Host to publish the live Agent that opening a session resumes. + * Wait for opening a session to publish its live Agent. * @param scaffold - the booted web scaffold. * @param sessionId - the opened session's identity. * @returns the registered Agent instance. @@ -82,9 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { it('shows a running background job in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) - // Point assertion, not a poll: `expect.poll` retries until a predicate - // holds, so polling for zero passes at t=0 and proves nothing. The - // "renders nothing without a task" branch is owned by the component suite. + // Polling for zero would pass at t=0 before delivery and prove nothing. const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -116,8 +111,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - // The trigger drops its live count once the task leaves running/stopping, - // which is also the proof that settlement reached the browser unprompted. const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 0e48e08ec0..96a0a4549b 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -8,8 +8,9 @@ // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction -// fixture's cross-plugin projection because only the built connection/runtime/ -// workspace graph can prove that transport-to-row path end to end. +// fixture's cross-plugin projection because only the built connection, +// Controller, UI adapter, and Workspace graph can prove that transport-to-row +// path end to end. import { resolve } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' @@ -129,3 +130,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(styleOwners).toContain(plugin) } }) + +it('boots without ui-chat and does not select another conversation view implicitly', async () => { + mountAssembledApp('?fixture', { exclude: ['@deepseek-ai/dsh-client-ui-chat'] }) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const boot = Reflect.get(window, '__DSH_BOOT__') as { entries: Array<{ id: string }> } | undefined + expect(boot?.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-chat')).toBe(false) + const sessionTitle = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(sessionTitle) + await waitFor(() => { + expect(document.querySelector('[data-slot="conversation.session"]')).not.toBeNull() + }, { timeout: 10_000 }) + expect(document.querySelector('[data-slot="conversation.view"]')).toBeNull() +}) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 2e1892f8b6..7c70741296 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -30,6 +30,7 @@ const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e' const REPLAY_CONTEXT_WINDOW = 10_000_000 const STREAM_PACE_MS = 24 const GEOMETRY_TOLERANCE = 2 +const RESPONSIVE_REFLOW_TOLERANCE = 32 const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.' const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST' const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE' @@ -395,11 +396,15 @@ function flowTop(page: Page, key: string): Promise { }, key) } -async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise { +async function expectSameFlowTop( + page: Page, + anchor: FlowAnchor, + tolerance = GEOMETRY_TOLERANCE, +): Promise { await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), { timeout: 10_000, message: `flow row ${anchor.key} moved relative to the transcript viewport`, - }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + }).toBeLessThanOrEqual(tolerance) } async function expectBottom(page: Page): Promise { @@ -482,12 +487,13 @@ describe('web e2e: long Chat scroll contract', () => { let releaseGate: (() => void) | undefined const gate = new Promise((resolve) => { releaseGate = resolve }) releaseHistory = () => { releaseGate?.() } - await world.page.route('**/api/session.history', async (route) => { + await world.page.route('**/api/session/page', async (route) => { const request = route.request().postDataJSON() as { method?: string - payload?: { beforeSeq?: number } + payload?: { args?: { request?: { beforeSeq?: number } } } } - if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) { + if (!held && request.method === 'session/page' + && request.payload?.args?.request?.beforeSeq !== undefined) { held = true await gate } @@ -524,7 +530,7 @@ describe('web e2e: long Chat scroll contract', () => { await settled await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) - await world.page.unroute('**/api/session.history') + await world.page.unroute('**/api/session/page') let additionalPages = 0 while (additionalPages < 8) { @@ -662,7 +668,8 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click() await world.page.getByRole('tab', { name: 'Chat', exact: true }).click() await nextPaint(world.page) - await expectSameFlowTop(world.page, sessionAnchor) + await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE) + const narrowSessionAnchor = await visibleFlowAnchor(world.page) await openSeed( world.page, @@ -673,7 +680,7 @@ describe('web e2e: long Chat scroll contract', () => { world.page, RESTORE_FIXTURE_A, ) - await expectSameFlowTop(world.page, sessionAnchor) + await expectSameFlowTop(world.page, narrowSessionAnchor) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) await backToBottom.evaluate((button) => { diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 051700dc35..342f9328e0 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -1,11 +1,4 @@ -// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME -// shipped tree with the tools row patched to mode: code (the run_code-only -// wire), a real chromium sends a prompt engineered to elicit one run_code -// program with several sub-calls, and the UI must render the code-variant -// parent row with its always-visible nested sub-rows — each sub-row the same -// component a native call renders through — plus details-panel resolution for -// a clicked sub-row. Drive steps wait only on generic completion -// (whenTurnSettled); assertion steps run in replay/refresh only. +// Code Mode browser round trip with nested sub-calls and details selection. // Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' @@ -24,9 +17,7 @@ const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() -// The scenario's one drive prompt: elicits one program with a bash sub-call -// and a failing read the program tolerates — the sub-row set the assertions -// need. Never asserted against model prose. +// Elicits the successful and failed sub-rows this scenario asserts. const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt ' + 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.' @@ -48,7 +39,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - // Fresh world: connect a Workspace so the composer scenarios start live. await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) @@ -60,7 +50,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { it('drives the recorded prompt to a settled turn (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive')) if (MODE !== 'record') { - // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } const input = page.locator('textarea').first() @@ -75,11 +64,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }, 200_000) it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => { - // Wire discipline: code mode collapsed the call surface to run_code. const calls = sessionEvents.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThanOrEqual(1) expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code'])) - // Sub-dispatches logged with the complete tool/result vocabulary. const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch') expect(dispatches.length).toBeGreaterThanOrEqual(2) for (const dispatch of dispatches) { @@ -107,14 +94,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { // description as its summary (the presentCall contract). const codeRow = page.locator('[data-variant="code"]').first() await codeRow.waitFor({ timeout: 10_000 }) - // Nested rows are visible WITHOUT any expand interaction, inside the - // sub-call nest, each rendered by the same components as native rows: - // the bash sub-call landed in the bash sample registration. const nest = page.locator('[data-subcalls]').first() await nest.waitFor({ timeout: 10_000 }) expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1) - // The failing read sub-call wears the same error state a native failed - // row wears (the recorded program tolerates a read of missing.txt). expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) }, 60_000) @@ -124,7 +106,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') await nest.locator('[data-sample="bash"]').first().click() - // Tool rows do not drive layout geometry; the Session's default panel stays closed. await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') }) diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index ae48dcddca..b8fa9a6121 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -1,35 +1,6 @@ -// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its -// GLYPHS AND ITS CARET AS ONE. -// -// The composer paints its text in two stacked layers (see -// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the -// `